|$ curl https://forge-ai.dev/api/markdown?path=docs/js/fetch
$cat docs/javascript-—-fetch-api.md
updated This week·40 min read·published

JavaScript — Fetch API

JavaScriptNetworkingAdvancedIntermediate to Advanced
Introduction

The Fetch API provides a modern, promise-based interface for making HTTP requests in the browser. It replaces XMLHttpRequest (XHR) with a cleaner, more flexible API that integrates naturally with service workers, streaming, and other modern web standards. The global fetch() method is the entry point — a simple function that takes a URL and returns a Promise resolving to a Response object.

Unlike XHR, fetch uses promises for asynchronous flow control, making it compatible with async/await syntax and promise chains. It also supports request and response streaming, enabling efficient handling of large payloads. The Fetch API is part of the WHATWG Fetch Standard and is implemented in all modern browsers and Node.js (since v18).

One important distinction: fetch only rejects on network errors (DNS failure, connection refused, etc.). HTTP error statuses like 404 or 500 are treated as successful responses — you must check response.ok or response.status to detect them.

fetch-intro.js
JavaScript
1// The simplest fetch — a GET request
2fetch('https://api.example.com/data')
3 .then(response => {
4 if (!response.ok) {
5 throw new Error(`HTTP error! status: ${response.status}`);
6 }
7 return response.json();
8 })
9 .then(data => console.log(data))
10 .catch(error => console.error('Fetch error:', error));
11
12// With async/await (preferred)
13async function getData() {
14 try {
15 const response = await fetch('https://api.example.com/data');
16 if (!response.ok) throw new Error(`HTTP ${response.status}`);
17 return await response.json();
18 } catch (error) {
19 console.error('Fetch failed:', error);
20 throw error;
21 }
22}
Basic GET Requests

A GET request retrieves data from a specified resource. It is the default method when no method option is provided. The URL can include query parameters for filtering, sorting, or pagination. Always handle the response correctly by checking the status before processing the body.

basic-get.js
JavaScript
1// Basic GET with query parameters
2async function getUsers(page = 1, limit = 10) {
3 const url = new URL('https://api.example.com/users');
4 url.searchParams.set('page', page.toString());
5 url.searchParams.set('limit', limit.toString());
6 url.searchParams.set('sort', '-createdAt');
7
8 const response = await fetch(url.toString());
9 if (!response.ok) {
10 throw new Error(`Failed to fetch users: ${response.status}`);
11 }
12 return response.json();
13}
14
15// GET with headers for auth
16async function getProfile(token) {
17 const response = await fetch('https://api.example.com/profile', {
18 headers: {
19 'Authorization': `Bearer ${token}`,
20 'Accept': 'application/json',
21 }
22 });
23 if (!response.ok) throw new Error('Auth failed');
24 return response.json();
25}
26
27// GET with cache control
28const response = await fetch(url, {
29 cache: 'no-cache', // forces revalidation
30 // cache: 'force-cache', // use cached when available
31 // cache: 'no-store', // never cache
32});
Request Methods

The Fetch API supports all standard HTTP methods. The method option controls which HTTP verb is used. Each method has semantic meaning — GET retrieves, POST creates, PUT replaces, PATCH updates partially, and DELETE removes. The server may enforce different behavior, validation, and status codes for each method.

MethodPurposeBodyIdempotentSafeTypical Status
GETRetrieve resourceNo200
POSTCreate resourceYes201
PUTReplace resourceYes200/204
PATCHPartial updateYes200
DELETERemove resourceOptional200/204
request-methods.js
JavaScript
1// POST — create a new resource
2async function createUser(userData) {
3 const response = await fetch('https://api.example.com/users', {
4 method: 'POST',
5 headers: { 'Content-Type': 'application/json' },
6 body: JSON.stringify(userData),
7 });
8 if (!response.ok) throw new Error(`Create failed: ${response.status}`);
9 return response.json(); // returns the created resource with ID
10}
11
12// PUT — full replacement
13async function replaceUser(id, userData) {
14 const response = await fetch(`https://api.example.com/users/${id}`, {
15 method: 'PUT',
16 headers: { 'Content-Type': 'application/json' },
17 body: JSON.stringify(userData),
18 });
19 if (response.status === 204) return; // No Content
20 return response.json();
21}
22
23// PATCH — partial update
24async function updateUser(id, changes) {
25 const response = await fetch(`https://api.example.com/users/${id}`, {
26 method: 'PATCH',
27 headers: { 'Content-Type': 'application/json' },
28 body: JSON.stringify(changes),
29 });
30 return response.json();
31}
32
33// DELETE
34async function deleteUser(id) {
35 const response = await fetch(`https://api.example.com/users/${id}`, {
36 method: 'DELETE',
37 });
38 if (response.status === 204) return;
39 if (!response.ok) throw new Error(`Delete failed: ${response.status}`);
40}
Headers & Body

The Headers interface allows you to set, get, and delete HTTP headers for both requests and responses. Headers are case-insensitive and immutable in responses (unless you clone them). The request body can be a string, FormData, Blob, ArrayBuffer, TypedArray, DataView, URLSearchParams, or ReadableStream.

headers-body.js
JavaScript
1// Using the Headers interface
2const headers = new Headers();
3headers.set('Content-Type', 'application/json');
4headers.set('Authorization', `Bearer ${token}`);
5headers.set('X-CSRF-Token', csrfToken);
6headers.append('Accept-Language', 'en-US');
7
8// Check and read headers
9console.log(headers.has('Authorization')); // true
10console.log(headers.get('Content-Type')); // "application/json"
11console.log([...headers]); // array of [key, value] pairs
12
13// Headers are iterable
14headers.forEach((value, key) => {
15 console.log(`${key}: ${value}`);
16});
17
18// Body types
19const jsonBody = JSON.stringify({ name: 'John' });
20
21const formData = new FormData();
22formData.append('file', fileInput.files[0]);
23formData.append('name', 'John');
24
25const urlEncoded = new URLSearchParams({
26 username: 'john',
27 password: 'secret123'
28});
29
30// Send different body types
31await fetch(url, { method: 'POST', body: jsonBody }); // JSON
32await fetch(url, { method: 'POST', body: formData }); // multipart/form-data
33await fetch(url, { method: 'POST', body: urlEncoded }); // application/x-www-form-urlencoded
34await fetch(url, { method: 'PUT', body: blob }); // binary data
35
36// Headers in request options
37const response = await fetch(url, {
38 method: 'POST',
39 headers: {
40 'Content-Type': 'application/json',
41 'Accept': 'application/json',
42 'Authorization': `Bearer ${token}`,
43 },
44 body: JSON.stringify(payload),
45});
46
47// Reading response headers
48console.log(response.headers.get('content-type'));
49console.log(response.headers.get('x-request-id'));
Response Handling

The Response object provides several methods to consume the body in different formats. Each method returns a promise and can only be called once — the response body is a ReadableStream that can be consumed a single time. Clone the response with response.clone() if you need to read it multiple times.

MethodReturnsUse Case
response.json()Promise<any>Parse JSON API responses
response.text()Promise<string>Read as plain text (HTML, CSV, XML)
response.blob()Promise<Blob>Binary data (images, PDFs, files)
response.formData()Promise<FormData>Parse multipart/form-data responses
response.arrayBuffer()Promise<ArrayBuffer>Raw binary buffer (audio, WebAssembly)
response-handling.js
JavaScript
1// JSON response (most common for APIs)
2async function fetchJSON(url) {
3 const res = await fetch(url);
4 if (!res.ok) throw new Error(`HTTP ${res.status}`);
5 return res.json(); // automatically parses JSON
6}
7
8// Text response (HTML, CSV, plain text)
9async function fetchText(url) {
10 const res = await fetch(url);
11 return res.text();
12}
13
14// Blob response (binary files)
15async function fetchImage(url) {
16 const res = await fetch(url);
17 const blob = await res.blob();
18 const imgUrl = URL.createObjectURL(blob);
19 document.querySelector('img').src = imgUrl;
20 // Clean up: URL.revokeObjectURL(imgUrl) when done
21}
22
23// FormData response
24async function fetchFormData(url) {
25 const res = await fetch(url);
26 const formData = await res.formData();
27 for (const [key, value] of formData) {
28 console.log(key, value);
29 }
30}
31
32// ArrayBuffer (low-level binary)
33async function fetchBuffer(url) {
34 const res = await fetch(url);
35 return res.arrayBuffer(); // useful for WebAssembly, audio processing
36}
37
38// Reading response metadata
39async function fetchWithMeta(url) {
40 const res = await fetch(url);
41 console.log('Status:', res.status, res.statusText);
42 console.log('OK:', res.ok);
43 console.log('Type:', res.type); // 'basic', 'cors', 'opaque', etc.
44 console.log('Redirected:', res.redirected);
45 console.log('Headers:', [...res.headers]);
46 return res.json();
47}
Error Handling

One of the most common mistakes with fetch is forgetting that HTTP error responses (4xx, 5xx) do NOT cause the promise to reject. The promise only rejects on network-level failures. You must explicitly check response.ok (true for status 200-299) or inspect response.status directly.

Error TypeFetch BehaviorDetection
HTTP ErrorResolves normally (not a rejection)Check response.ok or response.status
Network ErrorPromise rejects with TypeErrorcatch() or try/catch
CORS ErrorNetwork error (opaque response)type === 'opaque'
TimeoutNo built-in — use AbortControllerAbortError from signal
JSON Parse ErrorRejects when calling response.json()catch() on json() call
error-handling.js
JavaScript
1// Comprehensive error handling
2async function robustFetch(url, options = {}) {
3 try {
4 const response = await fetch(url, options);
5
6 // HTTP errors are NOT network errors — check explicitly
7 if (!response.ok) {
8 // Try to parse error body for details
9 let errorBody = null;
10 try {
11 errorBody = await response.json();
12 } catch {
13 errorBody = await response.text().catch(() => null);
14 }
15
16 throw new HTTPError(
17 `HTTP ${response.status}: ${response.statusText}`,
18 response.status,
19 errorBody
20 );
21 }
22
23 return await response.json();
24 } catch (error) {
25 if (error instanceof HTTPError) {
26 // Known HTTP error — handle gracefully
27 if (error.status === 404) return null;
28 if (error.status === 401) redirectToLogin();
29 if (error.status >= 500) showServerError();
30 } else if (error.name === 'AbortError') {
31 console.log('Request was cancelled');
32 return null;
33 } else if (error instanceof TypeError) {
34 // Network error (DNS, connection, CORS)
35 console.error('Network failure:', error.message);
36 showOfflineMessage();
37 } else {
38 // Unexpected error
39 console.error('Unexpected error:', error);
40 }
41 throw error;
42 }
43}
44
45// Custom error class for HTTP errors
46class HTTPError extends Error {
47 constructor(message, status, body) {
48 super(message);
49 this.name = 'HTTPError';
50 this.status = status;
51 this.body = body;
52 }
53}

danger

Remember: fetch only rejects on network errors, NOT on HTTP errors like 404 or 500. Always check response.ok or response.status. This is a common source of bugs — your .catch() handler will not fire for 404 responses.
AbortController

The AbortController interface allows you to cancel in-flight fetch requests. This is essential for race condition prevention in SPAs, search-as-you-type inputs, page navigation cleanup, and setting request timeouts. When a request is aborted, the fetch promise rejects with an AbortError.

abort-controller.js
JavaScript
1// Request timeout with AbortController
2async function fetchWithTimeout(url, timeoutMs = 5000) {
3 const controller = new AbortController();
4 const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
5
6 try {
7 const response = await fetch(url, {
8 signal: controller.signal,
9 });
10 return response.json();
11 } finally {
12 clearTimeout(timeoutId);
13 }
14}
15
16// Cancel stale requests (search-as-you-type)
17let currentController = null;
18
19async function search(query) {
20 // Cancel previous request
21 if (currentController) {
22 currentController.abort();
23 }
24
25 currentController = new AbortController();
26
27 try {
28 const results = await fetch(`/api/search?q=${query}`, {
29 signal: currentController.signal,
30 });
31 return results.json();
32 } catch (error) {
33 if (error.name === 'AbortError') return; // silently ignore
34 throw error;
35 }
36}
37
38// Abort signal for multiple requests
39const controller = new AbortController();
40const { signal } = controller;
41
42// Pass the same signal to multiple requests
43Promise.all([
44 fetch('/api/users', { signal }),
45 fetch('/api/posts', { signal }),
46 fetch('/api/comments', { signal }),
47])
48// Abort all if page navigates away
49controller.abort();
50
51// Using AbortController for cleanup in React
52useEffect(() => {
53 const controller = new AbortController();
54 fetch('/api/data', { signal: controller.signal })
55 .then(res => res.json())
56 .then(setData)
57 .catch(err => {
58 if (err.name !== 'AbortError') setError(err);
59 });
60 return () => controller.abort();
61}, []);
🔥

pro tip

Use AbortController for more than just timeouts. It is the standard way to cancel fetches when: the user navigates away, a component unmounts, a new search replaces the previous one, or a modal closes. The same signal can be shared across multiple requests to abort them all at once.
Request & Response Objects

The Request and Response objects provide structured interfaces for HTTP communication. Creating a Request object explicitly is useful when you need to reuse the same configuration, modify requests before sending, or pass them to service workers. Both objects implement the Body mixin, providing the same body-reading methods.

request-response.js
JavaScript
1// Creating and reusing a Request object
2const request = new Request('https://api.example.com/users', {
3 method: 'POST',
4 headers: {
5 'Content-Type': 'application/json',
6 'Authorization': `Bearer ${token}`,
7 },
8 body: JSON.stringify({ name: 'John' }),
9 cache: 'no-cache',
10 credentials: 'include',
11});
12
13// Request objects are immutable — clone to modify
14const clonedRequest = request.clone();
15const modifiedRequest = new Request(request, {
16 headers: { ...request.headers, 'X-Debug': 'true' },
17});
18
19// Request introspection
20console.log(request.url); // the full URL
21console.log(request.method); // "POST"
22console.log(request.headers); // Headers object
23console.log(request.bodyUsed); // false (can only read body once)
24console.log(request.cache); // "no-cache"
25console.log(request.credentials); // "include"
26console.log(request.mode); // "cors"
27console.log(request.referrer); // "about:client"
28
29// Creating a Response object (useful for service workers)
30const response = new Response(JSON.stringify({ success: true }), {
31 status: 200,
32 statusText: 'OK',
33 headers: {
34 'Content-Type': 'application/json',
35 'X-Custom': 'value',
36 },
37});
38
39// Response introspection
40console.log(response.status); // 200
41console.log(response.statusText); // "OK"
42console.log(response.ok); // true
43console.log(response.type); // "basic"
44console.log(response.headers); // Headers object
45console.log(response.body); // ReadableStream (raw)
46
47// Response redirects
48const redirectRes = await fetch('/old-path');
49console.log(redirectRes.redirected); // true if followed a redirect
50console.log(redirectRes.url); // final URL after redirects
CORS & Credentials

Cross-Origin Resource Sharing (CORS) is a security mechanism that controls how resources from one origin can be requested by a different origin. The browser enforces CORS policies based on response headers from the server. The credentials option controls whether cookies, TLS client certificates, and authorization headers are sent with cross-origin requests.

credentials ModeCookies SentUse Case
same-origin✓ (same origin only)Default — safe for APIs on same domain
include✓ (all origins)Cross-origin with cookies (requires ACAO header)
omitNever send credentials
mode OptionDescription
corsCross-origin allowed (requires server CORS headers)
same-originRejects cross-origin requests entirely
no-corsLimited cross-origin (opaque response, no body reading from JS)
navigateFor navigation requests (browser internal)
cors-credentials.js
JavaScript
1// Credentials modes
2// Same-origin (default) — cookies only for same origin
3await fetch('/api/data');
4
5// Include — send cookies even for cross-origin
6await fetch('https://api.other.com/data', {
7 credentials: 'include',
8});
9// Server must respond with:
10// Access-Control-Allow-Credentials: true
11// Access-Control-Allow-Origin: https://yourdomain.com (NOT *)
12
13// Omit — never send credentials
14await fetch('https://api.public.com/data', {
15 credentials: 'omit',
16});
17
18// CORS preflight — automatic for "non-simple" requests
19// Simple requests: GET, HEAD, POST with Content-Type: text/plain
20// Preflight triggered by: PUT, DELETE, PATCH, custom headers
21async function crossOriginRequest() {
22 const response = await fetch('https://api.other.com/create', {
23 method: 'POST',
24 credentials: 'include',
25 headers: {
26 'Content-Type': 'application/json',
27 'X-Custom-Header': 'value', // triggers preflight
28 },
29 body: JSON.stringify({ key: 'value' }),
30 });
31 // Browser will first send OPTIONS preflight
32 // Server must respond with:
33 // Access-Control-Allow-Origin: https://yourdomain.com
34 // Access-Control-Allow-Methods: POST, OPTIONS
35 // Access-Control-Allow-Headers: Content-Type, X-Custom-Header
36 return response.json();
37}

warning

CORS errors cannot be handled in JavaScript — the browser blocks the response and the fetch promise rejects with a network error. The only fix is on the server side: ensure it sends correct Access-Control-Allow-Origin headers. For development, use a proxy or disable CORS in the browser (not recommended for production).
Streaming Responses

The Fetch API supports streaming response bodies via the ReadableStream interface. Instead of waiting for the entire response to download, you can process chunks as they arrive. This is ideal for large files, real-time data feeds, and progressive rendering. Access the raw stream through response.body.

streaming.js
JavaScript
1// Reading a streamed response
2async function streamResponse(url) {
3 const response = await fetch(url);
4 const reader = response.body.getReader();
5 const decoder = new TextDecoder();
6
7 let buffer = '';
8
9 while (true) {
10 const { done, value } = await reader.read();
11 if (done) break;
12
13 // value is a Uint8Array
14 buffer += decoder.decode(value, { stream: true });
15
16 // Process partial data
17 processChunk(buffer);
18 }
19}
20
21// Streaming JSON (NDJSON — newline-delimited JSON)
22async function streamJSON(url) {
23 const response = await fetch(url);
24 const reader = response.body.getReader();
25 const decoder = new TextDecoder();
26 let buffer = '';
27
28 while (true) {
29 const { done, value } = await reader.read();
30 if (done) break;
31
32 buffer += decoder.decode(value, { stream: true });
33 const lines = buffer.split('\n');
34 buffer = lines.pop() || ''; // keep incomplete line
35
36 for (const line of lines) {
37 if (line.trim()) {
38 yield JSON.parse(line);
39 }
40 }
41 }
42}
43
44// Usage with for-await...of
45for await (const item of streamJSON('/api/events')) {
46 console.log('Streamed item:', item);
47}
48
49// Cancelling a stream
50reader.cancel(); // stops reading and releases the stream
51
52// Stream progress tracking
53async function fetchWithProgress(url, onProgress) {
54 const response = await fetch(url);
55 const total = parseInt(response.headers.get('content-length') || '0');
56 let loaded = 0;
57
58 const reader = response.body.getReader();
59 while (true) {
60 const { done, value } = await reader.read();
61 if (done) break;
62 loaded += value.length;
63 onProgress({ loaded, total, percent: (loaded / total) * 100 });
64 }
65}
Upload Progress

Unlike XMLHttpRequest, fetch does not have a built-in upload progress event. However, you can implement upload progress tracking using the ReadableStream API or by using XMLHttpRequest for upload-specific scenarios. For download progress, the streaming approach with response.body works well.

upload-progress.js
JavaScript
1// Upload progress using ReadableStream (advanced)
2function uploadWithProgress(url, file, onProgress) {
3 return new Promise((resolve, reject) => {
4 const xhr = new XMLHttpRequest();
5
6 xhr.upload.addEventListener('progress', (e) => {
7 if (e.lengthComputable) {
8 onProgress({
9 loaded: e.loaded,
10 total: e.total,
11 percent: Math.round((e.loaded / e.total) * 100),
12 });
13 }
14 });
15
16 xhr.addEventListener('load', () => {
17 if (xhr.status >= 200 && xhr.status < 300) {
18 resolve(JSON.parse(xhr.responseText));
19 } else {
20 reject(new Error(`Upload failed: ${xhr.status}`));
21 }
22 });
23
24 xhr.addEventListener('error', () => reject(new Error('Network error')));
25 xhr.addEventListener('abort', () => reject(new Error('Upload cancelled')));
26
27 xhr.open('POST', url);
28 xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
29 xhr.send(file);
30 });
31}
32
33// Download progress with fetch streaming
34async function downloadWithProgress(url, onProgress) {
35 const response = await fetch(url);
36 const total = parseInt(response.headers.get('content-length') || '0');
37 let loaded = 0;
38 const chunks = [];
39
40 const reader = response.body.getReader();
41 while (true) {
42 const { done, value } = await reader.read();
43 if (done) break;
44 chunks.push(value);
45 loaded += value.length;
46 onProgress({ loaded, total, percent: (loaded / total) * 100 });
47 }
48
49 // Combine chunks into a single Blob
50 return new Blob(chunks);
51}
52
53// Simplified progress with a utility function
54async function fetchWithDownloadProgress(url, onProgress) {
55 const res = await fetch(url);
56 const contentLength = res.headers.get('content-length');
57 const total = contentLength ? parseInt(contentLength) : 0;
58 let loaded = 0;
59
60 for await (const chunk of streamBody(res)) {
61 loaded += chunk.length;
62 onProgress({ loaded, total });
63 }
64}
65
66async function* streamBody(response) {
67 const reader = response.body.getReader();
68 while (true) {
69 const { done, value } = await reader.read();
70 if (done) return;
71 yield value;
72 }
73}
Interceptors Pattern

Unlike Axios, fetch does not have built-in interceptors. However, you can implement the interceptor pattern by wrapping fetch with before-request and after-response hooks. This pattern is useful for adding authentication tokens, logging, error normalization, retry logic, and request/response transformation across all API calls.

interceptors.js
JavaScript
1// Fetch wrapper with interceptors
2class HttpClient {
3 constructor(baseURL) {
4 this.baseURL = baseURL;
5 this.requestInterceptors = [];
6 this.responseInterceptors = [];
7 }
8
9 // Add a request interceptor (receives Request, returns modified Request)
10 addRequestInterceptor(fn) {
11 this.requestInterceptors.push(fn);
12 return () => {
13 this.requestInterceptors =
14 this.requestInterceptors.filter(i => i !== fn);
15 };
16 }
17
18 // Add a response interceptor (receives Response, returns modified Response)
19 addResponseInterceptor(fn) {
20 this.responseInterceptors.push(fn);
21 return () => {
22 this.responseInterceptors =
23 this.responseInterceptors.filter(i => i !== fn);
24 };
25 }
26
27 async request(url, options = {}) {
28 let request = new Request(
29 `${this.baseURL}${url}`,
30 options
31 );
32
33 // Apply request interceptors
34 for (const interceptor of this.requestInterceptors) {
35 request = await interceptor(request);
36 }
37
38 let response = await fetch(request);
39
40 // Apply response interceptors
41 for (const interceptor of this.responseInterceptors) {
42 response = await interceptor(response);
43 }
44
45 if (!response.ok) {
46 throw new HTTPError(
47 `${response.status} ${response.statusText}`,
48 response.status,
49 await response.json().catch(() => null)
50 );
51 }
52
53 return response.json();
54 }
55
56 get(url, opts) {
57 return this.request(url, { ...opts, method: 'GET' });
58 }
59 post(url, body, opts) {
60 return this.request(url, { ...opts, method: 'POST', body });
61 }
62 put(url, body, opts) {
63 return this.request(url, { ...opts, method: 'PUT', body });
64 }
65 delete(url, opts) {
66 return this.request(url, { ...opts, method: 'DELETE' });
67 }
68}
69
70// Usage
71const api = new HttpClient('https://api.example.com');
72
73// Auth interceptor
74api.addRequestInterceptor(async (request) => {
75 const token = await getAuthToken();
76 const headers = new Headers(request.headers);
77 headers.set('Authorization', `Bearer ${token}`);
78 return new Request(request, { headers });
79});
80
81// Retry interceptor (simple)
82api.addResponseInterceptor(async (response) => {
83 if (response.status === 429) {
84 await sleep(1000);
85 return fetch(response.url, { method: response.method });
86 }
87 return response;
88});
89
90// Logging interceptor
91api.addRequestInterceptor(async (req) => {
92 console.log(`[API] ${req.method} ${req.url}`);
93 return req;
94});
95
96// Use the client
97const users = await api.get('/users?page=1');
98const newUser = await api.post('/users', JSON.stringify({ name: 'John' }));
Fetch vs XMLHttpRequest

XMLHttpRequest (XHR) was the original API for making HTTP requests in the browser. While fetch is now the modern standard, XHR still has some unique capabilities. Understanding the differences helps you choose the right tool and maintain legacy code.

FeatureFetch APIXMLHttpRequest
Promise-based✓ (native promises)✗ (callback-based)
Streaming✓ (ReadableStream)✗ (full response only)
Upload Progress✗ (workaround needed)✓ (native events)
Download Progress✗ (stream reader needed)✓ (progress event)
Request Cancellation✓ (AbortController)✓ (abort())
Timeout✗ (via AbortController)✓ (timeout property)
Service Worker Integration✓ (native)
Response Type Control✓ (json, text, blob, etc.)✓ (responseType)
Browser SupportAll modern browsersUniversal (legacy + modern)
Synchronous Mode✗ (async only)✓ (deprecated, avoid)
fetch-vs-xhr.js
JavaScript
1// XHR equivalent of a fetch call
2function xhrGet(url) {
3 return new Promise((resolve, reject) => {
4 const xhr = new XMLHttpRequest();
5 xhr.open('GET', url);
6 xhr.responseType = 'json';
7 xhr.setRequestHeader('Accept', 'application/json');
8
9 xhr.onload = () => {
10 if (xhr.status >= 200 && xhr.status < 300) {
11 resolve(xhr.response);
12 } else {
13 reject(new Error(`${xhr.status}: ${xhr.statusText}`));
14 }
15 };
16
17 xhr.onerror = () => reject(new Error('Network error'));
18 xhr.ontimeout = () => reject(new Error('Request timed out'));
19 xhr.onabort = () => reject(new Error('Request aborted'));
20
21 xhr.timeout = 5000;
22 xhr.send();
23 });
24}
25
26// Same call with fetch
27async function fetchGet(url) {
28 const response = await fetch(url, {
29 headers: { 'Accept': 'application/json' },
30 signal: AbortSignal.timeout(5000), // modern browser timeout
31 });
32 if (!response.ok) {
33 throw new Error(`${response.status}: ${response.statusText}`);
34 }
35 return response.json();
36}
37
38// When to use XHR over fetch:
39// 1. Upload progress tracking (native XHR events)
40// 2. Legacy browser support (IE11 and below)
41// 3. Synchronous requests (main thread blocking — avoid if possible)
42// 4. When you need abort without AbortController polyfill

best practice

Use fetch for all new projects. It integrates naturally with service workers, streams, promises, and async/await. Reserve XMLHttpRequest for the specific use cases where it still excels: upload progress tracking and legacy browser support. For upload progress with fetch, consider wrapping XHR in a promise as shown above.
Best Practices
Always check response.ok — HTTP errors (404, 500) do not reject the fetch promise
Use AbortController for timeouts and cleanup — prevents race conditions and memory leaks
Centralize fetch logic in a client wrapper with interceptors for auth, logging, and error handling
Use response.clone() if you need to read the body multiple times — bodies are single-consumption streams
Set Content-Type headers explicitly — fetch does not set them automatically for JSON bodies
Handle JSON parse errors separately — response.json() can fail even when the HTTP status is OK
Use credentials: 'include' for cross-origin requests that need cookies (server must allow it)
Implement retry logic for transient failures (network blips, 429 rate limits, 503 service unavailable)
Prefer async/await over .then() chains for better error handling with try/catch
Cache responses when appropriate — use the Cache API or HTTP caching headers rather than reinventing
api-client.js
JavaScript
1// Production-ready fetch client
2class APIClient {
3 constructor(baseURL) {
4 this.baseURL = baseURL;
5 }
6
7 async request(endpoint, options = {}) {
8 const controller = new AbortController();
9 const timeoutId = setTimeout(() => controller.abort(), 10000);
10
11 try {
12 const response = await fetch(`${this.baseURL}${endpoint}`, {
13 ...options,
14 signal: controller.signal,
15 credentials: 'include',
16 headers: {
17 'Content-Type': 'application/json',
18 'Accept': 'application/json',
19 ...options.headers,
20 },
21 });
22
23 if (!response.ok) {
24 const error = await response.json().catch(() => ({}));
25 throw new APIError(
26 error.message || `HTTP ${response.status}`,
27 response.status,
28 error
29 );
30 }
31
32 return response.json();
33 } catch (error) {
34 if (error instanceof APIError) throw error;
35 if (error.name === 'AbortError') {
36 throw new APIError('Request timed out', 408);
37 }
38 throw new APIError('Network error', 0);
39 } finally {
40 clearTimeout(timeoutId);
41 }
42 }
43}
44
45class APIError extends Error {
46 constructor(message, status, body) {
47 super(message);
48 this.name = 'APIError';
49 this.status = status;
50 this.body = body;
51 }
52}
53
54export const api = new APIClient(import.meta.env.VITE_API_URL);
$Blueprint — Engineering Documentation·Section ID: JS-FETCH·Revision: 1.0