Bab 3: Network Requests
3.1 Fetch — Dasar
Apa Itu Fetch?
fetch() adalah cara modern JavaScript untuk mengambil data dari server (API). Menggantikan XMLHttpRequest yang ribet.
Analogi: Fetch itu kayak pesan makanan online — kamu kirim pesanan (request), tunggu, lalu terima paket (response). Prosesnya async (kamu bisa ngerjain hal lain sambil nunggu).
Sintaks Dasar
const response = await fetch(url, options);GET Request (Ambil Data)
// Ambil data dari API
const response = await fetch('https://jsonplaceholder.typicode.com/users/1');
// Cek apakah berhasil
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
// Parse JSON
const user = await response.json();
console.log(user.name); // "Leanne Graham"
console.log(user.email); // "Sincere@april.biz"Response Properties
const response = await fetch('https://api.example.com/data');
console.log(response.status); // 200, 404, 500, dll
console.log(response.ok); // true kalau status 200-299
console.log(response.statusText); // "OK", "Not Found", dll
console.log(response.headers.get('Content-Type')); // "application/json"
console.log(response.url); // URL final (setelah redirect)Membaca Response Body
const response = await fetch(url);
// Pilih SATU cara baca (body hanya bisa dibaca sekali!)
const json = await response.json(); // parse sebagai JSON
const text = await response.text(); // sebagai string
const blob = await response.blob(); // sebagai Blob (file)
const buffer = await response.arrayBuffer(); // sebagai ArrayBuffer
const form = await response.formData(); // sebagai FormDataPOST Request (Kirim Data)
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
nama: 'Yazid',
email: 'yazid@example.com',
umur: 25
})
});
const result = await response.json();
console.log(result); // { id: 101, nama: "Yazid", ... }Headers
// Buat headers
const headers = new Headers();
headers.append('Content-Type', 'application/json');
headers.append('Authorization', 'Bearer token123');
// Atau langsung di fetch
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token123',
'X-Custom-Header': 'nilai-custom'
}
});
// Baca response headers
for (const [key, value] of response.headers) {
console.log(`${key}: ${value}`);
}- fetch TIDAK throw error untuk 404/500! Hanya throw untuk network error
// ❌ Ini TIDAK akan catch 404
try {
const res = await fetch('/not-found');
// res.ok = false, tapi TIDAK throw error!
} catch (e) {
// Hanya masuk sini kalau NETWORK error (offline, DNS gagal)
}
// ✅ Cek manual
const res = await fetch('/not-found');
if (!res.ok) throw new Error(`Status: ${res.status}`);- Body hanya bisa dibaca SEKALI
const res = await fetch(url);
const json = await res.json();
const text = await res.text(); // ❌ Error! Body sudah dibaca3.2 FormData
Apa Itu FormData?
FormData adalah cara mengirim data form (termasuk file!) ke server. Browser otomatis set Content-Type: multipart/form-data.
Analogi: FormData itu kayak amplop besar yang bisa isi macam-macam — teks, foto, dokumen — semuanya dalam satu kiriman.
Membuat FormData
// Dari form HTML
const form = document.getElementById('myForm');
const formData = new FormData(form); // otomatis ambil semua input
// Manual
const formData = new FormData();
formData.append('nama', 'Yazid');
formData.append('email', 'yazid@example.com');
formData.append('umur', '25');Upload File dengan FormData
<form id="uploadForm">
<input type="text" name="judul" value="Foto Liburan">
<input type="file" name="foto" id="fotoInput">
<button type="submit">Upload</button>
</form>document.getElementById('uploadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData // JANGAN set Content-Type manual!
});
const result = await response.json();
console.log('Upload berhasil:', result);
});Metode FormData
const fd = new FormData();
// Tambah field
fd.append('hobi', 'coding');
fd.append('hobi', 'gaming'); // append = bisa duplikat key
// Set field (replace kalau sudah ada)
fd.set('nama', 'Yazid');
fd.set('nama', 'Budi'); // replace, bukan tambah
// Hapus
fd.delete('hobi');
// Cek ada/tidak
fd.has('nama'); // true
// Ambil nilai
fd.get('nama'); // "Budi" (pertama)
fd.getAll('hobi'); // [] (sudah dihapus)
// Iterasi
for (const [key, value] of fd) {
console.log(`${key}: ${value}`);
}Upload File dari Blob
// Buat file dari canvas
const canvas = document.getElementById('myCanvas');
canvas.toBlob(async (blob) => {
const formData = new FormData();
formData.append('gambar', blob, 'screenshot.png'); // param ke-3 = nama file
await fetch('/api/upload', {
method: 'POST',
body: formData
});
}, 'image/png');- JANGAN set Content-Type manual saat pakai FormData — browser perlu set boundary sendiri
// ❌ SALAH
fetch(url, {
headers: { 'Content-Type': 'multipart/form-data' }, // JANGAN!
body: formData
});
// ✅ BENAR - biarkan browser handle
fetch(url, { method: 'POST', body: formData });3.3 Fetch: Download Progress
Melacak Progress Download
async function downloadWithProgress(url) {
const response = await fetch(url);
// Ambil total ukuran dari header
const total = +response.headers.get('Content-Length');
// Baca body sebagai stream
const reader = response.body.getReader();
let received = 0;
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
const percent = Math.round((received / total) * 100);
console.log(`Download: ${percent}% (${received}/${total} bytes)`);
}
// Gabungkan semua chunks jadi satu
const allChunks = new Uint8Array(received);
let position = 0;
for (const chunk of chunks) {
allChunks.set(chunk, position);
position += chunk.length;
}
// Konversi ke hasil yang diinginkan
const text = new TextDecoder().decode(allChunks);
return JSON.parse(text);
}
// Penggunaan
const data = await downloadWithProgress('https://api.example.com/big-data');Dengan Progress Bar UI
async function downloadFile(url, progressBar) {
const response = await fetch(url);
const total = +response.headers.get('Content-Length');
const reader = response.body.getReader();
let received = 0;
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
// Update progress bar
progressBar.value = (received / total) * 100;
progressBar.textContent = `${Math.round((received / total) * 100)}%`;
}
const blob = new Blob(chunks);
return blob;
}- Content-Length mungkin tidak ada: Server tidak wajib kirim header ini
- Upload progress:
fetchTIDAK support upload progress! PakaiXMLHttpRequestuntuk itu
3.4 Fetch: Abort
Membatalkan Request
Kadang kamu perlu batalkan request — misal user pindah halaman, atau ketik di search box (batalkan request sebelumnya).
// Buat controller
const controller = new AbortController();
// Mulai fetch dengan signal
const response = fetch('https://api.example.com/data', {
signal: controller.signal
});
// Batalkan setelah 5 detik
setTimeout(() => controller.abort(), 5000);
try {
const data = await response;
} catch (err) {
if (err.name === 'AbortError') {
console.log('Request dibatalkan!');
} else {
throw err; // error lain
}
}Contoh: Search dengan Debounce + Abort
let currentController = null;
async function search(query) {
// Batalkan request sebelumnya
if (currentController) {
currentController.abort();
}
// Buat controller baru
currentController = new AbortController();
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: currentController.signal
});
const results = await response.json();
displayResults(results);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Search error:', err);
}
// AbortError = normal, abaikan saja
}
}
// Di input handler
searchInput.addEventListener('input', (e) => {
search(e.target.value);
});Timeout dengan AbortSignal
// Modern way (AbortSignal.timeout)
const response = await fetch(url, {
signal: AbortSignal.timeout(5000) // timeout 5 detik
});
// Manual way (untuk browser lama)
function fetchWithTimeout(url, timeout = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
return fetch(url, { signal: controller.signal })
.finally(() => clearTimeout(timeoutId));
}3.5 Fetch: Cross-Origin Requests (CORS)
Apa Itu CORS?
CORS (Cross-Origin Resource Sharing) adalah mekanisme keamanan browser. Browser BLOKIR request ke domain lain kecuali server mengizinkan.
Analogi: CORS itu kayak security gedung. Kamu (browser) mau masuk gedung lain (server lain). Security tanya: "Kamu dari mana? Boleh masuk nggak?" Kalau server bilang boleh, baru bisa masuk.
Kenapa Ada CORS?
// Kamu di https://mysite.com
// Mau ambil data dari https://api.other.com
const res = await fetch('https://api.other.com/data');
// ❌ BLOCKED by CORS policy!
// Browser: "Server api.other.com tidak izinkan akses dari mysite.com"Cara Kerja CORS
- Simple Request (GET, POST dengan Content-Type biasa) → Browser langsung kirim, cek response header
- Preflight Request (PUT, DELETE, custom headers) → Browser kirim OPTIONS dulu, baru request asli
// Simple request - langsung kirim
fetch('https://api.other.com/data'); // GET, no custom headers
// Preflight request - browser kirim OPTIONS dulu
fetch('https://api.other.com/data', {
method: 'PUT',
headers: { 'X-Custom': 'value' }
});
// Browser: OPTIONS /data → server jawab "boleh" → baru PUT /dataResponse Headers yang Mengizinkan CORS
// Server harus kirim header ini:
Access-Control-Allow-Origin: https://mysite.com // atau * untuk semua
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400 // cache preflight 24 jam
Credentials (Cookie) Cross-Origin
// Default: cookie TIDAK dikirim cross-origin
// Untuk kirim cookie:
const res = await fetch('https://api.other.com/data', {
credentials: 'include' // kirim cookie
});
// Server harus respond:
// Access-Control-Allow-Credentials: true
// Access-Control-Allow-Origin: https://mysite.com (TIDAK boleh *)- CORS itu aturan BROWSER: Server-to-server tidak kena CORS. Postman juga tidak
Access-Control-Allow-Origin: *+ credentials = DITOLAK: Harus spesifik origin- Preflight di-cache: Kalau server ubah CORS policy, mungkin perlu tunggu cache expire
3.6 Fetch API — Opsi Lengkap
Semua Opsi fetch()
const response = await fetch(url, {
// Method
method: 'POST', // GET, POST, PUT, DELETE, PATCH
// Headers
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token123'
},
// Body (untuk POST/PUT/PATCH)
body: JSON.stringify(data), // atau FormData, Blob, string
// Mode
mode: 'cors', // cors, no-cors, same-origin
// Credentials (cookie)
credentials: 'same-origin', // omit, same-origin, include
// Cache
cache: 'default', // default, no-store, reload, no-cache, force-cache
// Redirect
redirect: 'follow', // follow, error, manual
// Referrer
referrer: '', // URL referrer atau '' untuk tidak kirim
// Signal (untuk abort)
signal: controller.signal,
// Keepalive (untuk beacon/analytics saat page unload)
keepalive: true
});Contoh: API Wrapper Lengkap
class ApiClient {
constructor(baseUrl, token) {
this.baseUrl = baseUrl;
this.token = token;
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const config = {
headers: {
'Content-Type': 'application/json',
...(this.token && { 'Authorization': `Bearer ${this.token}` }),
...options.headers
},
...options
};
if (config.body && typeof config.body === 'object') {
config.body = JSON.stringify(config.body);
}
const response = await fetch(url, config);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
get(endpoint) {
return this.request(endpoint);
}
post(endpoint, data) {
return this.request(endpoint, { method: 'POST', body: data });
}
put(endpoint, data) {
return this.request(endpoint, { method: 'PUT', body: data });
}
delete(endpoint) {
return this.request(endpoint, { method: 'DELETE' });
}
}
// Penggunaan
const api = new ApiClient('https://api.example.com', 'myToken123');
const users = await api.get('/users');
const newUser = await api.post('/users', { nama: 'Yazid' });3.7 URL Objects
Membuat & Memanipulasi URL
// Buat URL object
const url = new URL('https://site.com:8080/path/page?name=yazid&age=25#section1');
console.log(url.protocol); // "https:"
console.log(url.host); // "site.com:8080"
console.log(url.hostname); // "site.com"
console.log(url.port); // "8080"
console.log(url.pathname); // "/path/page"
console.log(url.search); // "?name=yazid&age=25"
console.log(url.hash); // "#section1"
console.log(url.origin); // "https://site.com:8080"SearchParams — Kelola Query String
const url = new URL('https://api.example.com/search');
// Tambah parameter
url.searchParams.set('q', 'javascript tutorial');
url.searchParams.set('page', '1');
url.searchParams.set('limit', '10');
console.log(url.toString());
// "https://api.example.com/search?q=javascript+tutorial&page=1&limit=10"
// Otomatis encode karakter spesial!
url.searchParams.set('filter', 'nama=Yazid&umur>20');
// filter=nama%3DYazid%26umur%3E20 (di-encode otomatis)// Baca parameter
const url = new URL('https://site.com?a=1&b=2&a=3');
url.searchParams.get('a'); // "1" (pertama)
url.searchParams.getAll('a'); // ["1", "3"] (semua)
url.searchParams.has('b'); // true
// Hapus
url.searchParams.delete('a');
// Iterasi
for (const [key, value] of url.searchParams) {
console.log(`${key} = ${value}`);
}
// Sort
url.searchParams.sort();Contoh: Build API URL Dinamis
function buildApiUrl(base, params) {
const url = new URL(base);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, value);
}
}
return url.toString();
}
const apiUrl = buildApiUrl('https://api.example.com/products', {
category: 'elektronik',
minPrice: 100000,
maxPrice: 5000000,
sort: 'price_asc',
page: 1
});
// "https://api.example.com/products?category=elektronik&minPrice=100000&..."3.8 XMLHttpRequest
Kenapa Masih Perlu Tahu?
XMLHttpRequest (XHR) adalah cara lama untuk request HTTP. Masih berguna untuk:
- Upload progress (fetch belum support)
- Legacy code yang masih pakai XHR
- Browser sangat lama yang belum support fetch
Sintaks Dasar
// GET request
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/data');
xhr.onload = function() {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
console.log(data);
} else {
console.error(`Error: ${xhr.status}`);
}
};
xhr.onerror = function() {
console.error('Network error');
};
xhr.send();POST dengan XHR
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://api.example.com/users');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function() {
console.log(JSON.parse(xhr.responseText));
};
xhr.send(JSON.stringify({ nama: 'Yazid', email: 'yazid@mail.com' }));Upload Progress (Keunggulan XHR)
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
// UPLOAD progress (ini yang fetch belum bisa!)
xhr.upload.onprogress = function(event) {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
console.log(`Upload: ${percent}%`);
progressBar.value = percent;
}
};
xhr.upload.onload = () => console.log('Upload selesai!');
xhr.upload.onerror = () => console.error('Upload gagal!');
const formData = new FormData();
formData.append('file', fileInput.files[0]);
xhr.send(formData);- Callback hell: XHR pakai event, bukan Promise. Bungkus dengan Promise kalau perlu
- Pakai fetch untuk project baru: XHR hanya untuk kasus khusus (upload progress)
3.9 Resumable File Upload
Konsep
Upload file besar bisa gagal di tengah jalan (koneksi putus). Resumable upload = bisa lanjut dari posisi terakhir.
class ResumableUploader {
constructor(file, url) {
this.file = file;
this.url = url;
this.chunkSize = 1024 * 1024; // 1MB per chunk
}
async getUploadedSize() {
// Tanya server: sudah terima berapa byte?
const response = await fetch(this.url, {
method: 'HEAD',
headers: { 'X-File-Id': this.file.name + '-' + this.file.size }
});
return +response.headers.get('X-Uploaded-Size') || 0;
}
async upload(onProgress) {
const startByte = await this.getUploadedSize();
if (startByte >= this.file.size) {
console.log('File sudah terupload sepenuhnya!');
return;
}
console.log(`Melanjutkan dari byte ${startByte}...`);
// Potong file dari posisi terakhir
const chunk = this.file.slice(startByte);
const xhr = new XMLHttpRequest();
xhr.open('POST', this.url);
xhr.setRequestHeader('X-File-Id', this.file.name + '-' + this.file.size);
xhr.setRequestHeader('X-Start-Byte', startByte);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.upload.onprogress = (e) => {
const uploaded = startByte + e.loaded;
const percent = Math.round((uploaded / this.file.size) * 100);
onProgress?.(percent);
};
return new Promise((resolve, reject) => {
xhr.onload = () => resolve(xhr.response);
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.send(chunk);
});
}
}
// Penggunaan
const uploader = new ResumableUploader(file, '/api/upload');
await uploader.upload((percent) => {
console.log(`Progress: ${percent}%`);
});3.10 Long Polling
Apa Itu Long Polling?
Cara sederhana untuk dapat update real-time dari server. Client kirim request, server TAHAN sampai ada data baru, baru respond.
Analogi: Kayak kamu telpon restoran: "Ada pesanan baru?" Mereka bilang "Tunggu ya..." dan baru jawab kalau memang ada pesanan masuk. Begitu dijawab, kamu langsung telpon lagi.
async function longPoll(url) {
while (true) {
try {
const response = await fetch(url);
if (response.status === 502) {
// Timeout dari server, coba lagi
continue;
}
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data = await response.json();
handleNewData(data); // proses data baru
// Langsung poll lagi
} catch (err) {
if (err.name === 'AbortError') break;
console.error('Poll error:', err);
// Tunggu sebentar sebelum retry
await new Promise(r => setTimeout(r, 1000));
}
}
}
// Mulai polling
longPoll('/api/messages/subscribe');Kapan Pakai Long Polling vs WebSocket?
- Long Polling: Sederhana, works everywhere, cocok untuk update jarang
- WebSocket: Lebih efisien untuk update sering (chat, game, live data)
3.11 WebSocket
Apa Itu WebSocket?
WebSocket adalah koneksi dua arah yang TETAP TERBUKA antara browser dan server. Beda dengan HTTP yang buka-tutup terus.
Analogi: HTTP itu kayak kirim SMS — satu pesan, satu balasan, selesai. WebSocket itu kayak telepon — sekali tersambung, bisa ngobrol bolak-balik tanpa putus.
Membuat Koneksi
// Buat koneksi WebSocket
const socket = new WebSocket('wss://server.example.com/chat');
// ws:// = tanpa enkripsi, wss:// = dengan enkripsi (selalu pakai wss!)
// Event: koneksi terbuka
socket.onopen = () => {
console.log('Terhubung ke server!');
socket.send('Halo server!');
};
// Event: terima pesan
socket.onmessage = (event) => {
console.log('Pesan dari server:', event.data);
};
// Event: koneksi tertutup
socket.onclose = (event) => {
if (event.wasClean) {
console.log(`Koneksi ditutup bersih. Code: ${event.code}`);
} else {
console.log('Koneksi terputus!');
}
};
// Event: error
socket.onerror = (error) => {
console.error('WebSocket error:', error);
};Kirim Data
// Kirim string
socket.send('Halo!');
// Kirim JSON
socket.send(JSON.stringify({
type: 'chat',
message: 'Apa kabar?',
user: 'Yazid'
}));
// Kirim binary (Blob atau ArrayBuffer)
const blob = new Blob(['binary data'], { type: 'application/octet-stream' });
socket.send(blob);Contoh: Chat Sederhana
class ChatClient {
constructor(url) {
this.connect(url);
}
connect(url) {
this.socket = new WebSocket(url);
this.socket.onopen = () => {
console.log('Chat connected!');
};
this.socket.onmessage = (event) => {
const msg = JSON.parse(event.data);
this.displayMessage(msg);
};
this.socket.onclose = () => {
console.log('Disconnected. Reconnecting in 3s...');
setTimeout(() => this.connect(url), 3000);
};
}
send(message) {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({
type: 'message',
text: message,
timestamp: Date.now()
}));
}
}
displayMessage(msg) {
const div = document.createElement('div');
div.textContent = `${msg.user}: ${msg.text}`;
document.getElementById('chatBox').appendChild(div);
}
}
const chat = new ChatClient('wss://chat.example.com');
// Kirim pesan
sendButton.onclick = () => {
chat.send(inputField.value);
inputField.value = '';
};Close Codes
// Tutup koneksi dengan code dan reason
socket.close(1000, 'Selesai'); // 1000 = normal closure
// Common codes:
// 1000 - Normal closure
// 1001 - Going away (halaman ditutup)
// 1006 - Abnormal closure (koneksi putus)
// 1011 - Server error3.12 Server-Sent Events (SSE)
Apa Itu SSE?
SSE adalah koneksi satu arah: server → client. Lebih sederhana dari WebSocket, cocok untuk notifikasi, live feed, progress update.
Analogi: SSE itu kayak radio — kamu cuma dengar (terima), nggak bisa ngomong balik. Tapi koneksinya tetap nyala terus.
Penggunaan
// Buat koneksi SSE
const source = new EventSource('/api/notifications');
// Terima pesan (event "message")
source.onmessage = (event) => {
console.log('Data:', event.data);
console.log('ID:', event.lastEventId);
};
// Event custom dari server
source.addEventListener('notification', (event) => {
const data = JSON.parse(event.data);
showNotification(data.title, data.body);
});
source.addEventListener('progress', (event) => {
updateProgressBar(+event.data);
});
// Error handling
source.onerror = () => {
console.log('SSE error, akan reconnect otomatis...');
};
// Tutup koneksi
source.close();Keunggulan SSE vs WebSocket
| Fitur | SSE | WebSocket |
|---|---|---|
| Arah | Server → Client | Dua arah |
| Protocol | HTTP biasa | Protocol khusus (ws://) |
| Reconnect | Otomatis! | Manual |
| Kompleksitas | Sederhana | Lebih kompleks |
| Binary data | Tidak | Ya |
Contoh: Live Notification
function connectNotifications() {
const source = new EventSource('/api/events');
source.addEventListener('new-order', (e) => {
const order = JSON.parse(e.data);
alert(`Pesanan baru dari ${order.customer}!`);
});
source.addEventListener('stock-update', (e) => {
const item = JSON.parse(e.data);
updateStockDisplay(item.id, item.quantity);
});
source.onerror = () => {
// EventSource otomatis reconnect!
console.log('Koneksi terputus, reconnecting...');
};
return source;
}
const notifications = connectNotifications();
// Tutup saat halaman ditutup
window.addEventListener('beforeunload', () => {
notifications.close();
});- SSE hanya satu arah: Kalau butuh kirim data ke server, pakai fetch/POST terpisah
- Max connections: Browser batasi ~6 koneksi SSE per domain
- Tidak support binary: Hanya teks (UTF-8)
🏆 Challenge
Buat "API Explorer" sederhana:
- Input URL dan method (GET/POST/PUT/DELETE)
- Input untuk headers (key-value pairs)
- Input untuk body (JSON)
- Tombol "Send" yang mengirim request pakai fetch
- Tampilkan: status code, response headers, response body (formatted JSON)
- Tombol "Cancel" yang abort request yang sedang berjalan
- Tampilkan waktu response (berapa ms)
// Hint:
// - Pakai AbortController untuk cancel
// - performance.now() untuk hitung waktu
// - JSON.stringify(data, null, 2) untuk format JSON
// - try/catch untuk handle errorSudah paham materi ini?
Tandai sebagai selesai untuk melacak progress-mu.