JavaScript — Fetch API
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.
| 1 | // The simplest fetch — a GET request |
| 2 | fetch('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) |
| 13 | async 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 | } |
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.
| 1 | // Basic GET with query parameters |
| 2 | async 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 |
| 16 | async 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 |
| 28 | const 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 | }); |
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.
| Method | Purpose | Body | Idempotent | Safe | Typical Status |
|---|---|---|---|---|---|
| GET | Retrieve resource | No | ✓ | ✓ | 200 |
| POST | Create resource | Yes | ✗ | ✗ | 201 |
| PUT | Replace resource | Yes | ✓ | ✗ | 200/204 |
| PATCH | Partial update | Yes | ✗ | ✗ | 200 |
| DELETE | Remove resource | Optional | ✓ | ✗ | 200/204 |
| 1 | // POST — create a new resource |
| 2 | async 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 |
| 13 | async 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 |
| 24 | async 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 |
| 34 | async 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 | } |
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.
| 1 | // Using the Headers interface |
| 2 | const headers = new Headers(); |
| 3 | headers.set('Content-Type', 'application/json'); |
| 4 | headers.set('Authorization', `Bearer ${token}`); |
| 5 | headers.set('X-CSRF-Token', csrfToken); |
| 6 | headers.append('Accept-Language', 'en-US'); |
| 7 | |
| 8 | // Check and read headers |
| 9 | console.log(headers.has('Authorization')); // true |
| 10 | console.log(headers.get('Content-Type')); // "application/json" |
| 11 | console.log([...headers]); // array of [key, value] pairs |
| 12 | |
| 13 | // Headers are iterable |
| 14 | headers.forEach((value, key) => { |
| 15 | console.log(`${key}: ${value}`); |
| 16 | }); |
| 17 | |
| 18 | // Body types |
| 19 | const jsonBody = JSON.stringify({ name: 'John' }); |
| 20 | |
| 21 | const formData = new FormData(); |
| 22 | formData.append('file', fileInput.files[0]); |
| 23 | formData.append('name', 'John'); |
| 24 | |
| 25 | const urlEncoded = new URLSearchParams({ |
| 26 | username: 'john', |
| 27 | password: 'secret123' |
| 28 | }); |
| 29 | |
| 30 | // Send different body types |
| 31 | await fetch(url, { method: 'POST', body: jsonBody }); // JSON |
| 32 | await fetch(url, { method: 'POST', body: formData }); // multipart/form-data |
| 33 | await fetch(url, { method: 'POST', body: urlEncoded }); // application/x-www-form-urlencoded |
| 34 | await fetch(url, { method: 'PUT', body: blob }); // binary data |
| 35 | |
| 36 | // Headers in request options |
| 37 | const 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 |
| 48 | console.log(response.headers.get('content-type')); |
| 49 | console.log(response.headers.get('x-request-id')); |
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.
| Method | Returns | Use 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) |
| 1 | // JSON response (most common for APIs) |
| 2 | async 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) |
| 9 | async function fetchText(url) { |
| 10 | const res = await fetch(url); |
| 11 | return res.text(); |
| 12 | } |
| 13 | |
| 14 | // Blob response (binary files) |
| 15 | async 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 |
| 24 | async 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) |
| 33 | async function fetchBuffer(url) { |
| 34 | const res = await fetch(url); |
| 35 | return res.arrayBuffer(); // useful for WebAssembly, audio processing |
| 36 | } |
| 37 | |
| 38 | // Reading response metadata |
| 39 | async 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 | } |
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 Type | Fetch Behavior | Detection |
|---|---|---|
| HTTP Error | Resolves normally (not a rejection) | Check response.ok or response.status |
| Network Error | Promise rejects with TypeError | catch() or try/catch |
| CORS Error | Network error (opaque response) | type === 'opaque' |
| Timeout | No built-in — use AbortController | AbortError from signal |
| JSON Parse Error | Rejects when calling response.json() | catch() on json() call |
| 1 | // Comprehensive error handling |
| 2 | async 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 |
| 46 | class 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
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.
| 1 | // Request timeout with AbortController |
| 2 | async 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) |
| 17 | let currentController = null; |
| 18 | |
| 19 | async 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 |
| 39 | const controller = new AbortController(); |
| 40 | const { signal } = controller; |
| 41 | |
| 42 | // Pass the same signal to multiple requests |
| 43 | Promise.all([ |
| 44 | fetch('/api/users', { signal }), |
| 45 | fetch('/api/posts', { signal }), |
| 46 | fetch('/api/comments', { signal }), |
| 47 | ]) |
| 48 | // Abort all if page navigates away |
| 49 | controller.abort(); |
| 50 | |
| 51 | // Using AbortController for cleanup in React |
| 52 | useEffect(() => { |
| 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
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.
| 1 | // Creating and reusing a Request object |
| 2 | const 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 |
| 14 | const clonedRequest = request.clone(); |
| 15 | const modifiedRequest = new Request(request, { |
| 16 | headers: { ...request.headers, 'X-Debug': 'true' }, |
| 17 | }); |
| 18 | |
| 19 | // Request introspection |
| 20 | console.log(request.url); // the full URL |
| 21 | console.log(request.method); // "POST" |
| 22 | console.log(request.headers); // Headers object |
| 23 | console.log(request.bodyUsed); // false (can only read body once) |
| 24 | console.log(request.cache); // "no-cache" |
| 25 | console.log(request.credentials); // "include" |
| 26 | console.log(request.mode); // "cors" |
| 27 | console.log(request.referrer); // "about:client" |
| 28 | |
| 29 | // Creating a Response object (useful for service workers) |
| 30 | const 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 |
| 40 | console.log(response.status); // 200 |
| 41 | console.log(response.statusText); // "OK" |
| 42 | console.log(response.ok); // true |
| 43 | console.log(response.type); // "basic" |
| 44 | console.log(response.headers); // Headers object |
| 45 | console.log(response.body); // ReadableStream (raw) |
| 46 | |
| 47 | // Response redirects |
| 48 | const redirectRes = await fetch('/old-path'); |
| 49 | console.log(redirectRes.redirected); // true if followed a redirect |
| 50 | console.log(redirectRes.url); // final URL after redirects |
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 Mode | Cookies Sent | Use Case |
|---|---|---|
| same-origin | ✓ (same origin only) | Default — safe for APIs on same domain |
| include | ✓ (all origins) | Cross-origin with cookies (requires ACAO header) |
| omit | ✗ | Never send credentials |
| mode Option | Description |
|---|---|
| cors | Cross-origin allowed (requires server CORS headers) |
| same-origin | Rejects cross-origin requests entirely |
| no-cors | Limited cross-origin (opaque response, no body reading from JS) |
| navigate | For navigation requests (browser internal) |
| 1 | // Credentials modes |
| 2 | // Same-origin (default) — cookies only for same origin |
| 3 | await fetch('/api/data'); |
| 4 | |
| 5 | // Include — send cookies even for cross-origin |
| 6 | await 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 |
| 14 | await 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 |
| 21 | async 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
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.
| 1 | // Reading a streamed response |
| 2 | async 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) |
| 22 | async 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 |
| 45 | for await (const item of streamJSON('/api/events')) { |
| 46 | console.log('Streamed item:', item); |
| 47 | } |
| 48 | |
| 49 | // Cancelling a stream |
| 50 | reader.cancel(); // stops reading and releases the stream |
| 51 | |
| 52 | // Stream progress tracking |
| 53 | async 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 | } |
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.
| 1 | // Upload progress using ReadableStream (advanced) |
| 2 | function 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 |
| 34 | async 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 |
| 54 | async 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 | |
| 66 | async 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 | } |
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.
| 1 | // Fetch wrapper with interceptors |
| 2 | class 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 |
| 71 | const api = new HttpClient('https://api.example.com'); |
| 72 | |
| 73 | // Auth interceptor |
| 74 | api.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) |
| 82 | api.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 |
| 91 | api.addRequestInterceptor(async (req) => { |
| 92 | console.log(`[API] ${req.method} ${req.url}`); |
| 93 | return req; |
| 94 | }); |
| 95 | |
| 96 | // Use the client |
| 97 | const users = await api.get('/users?page=1'); |
| 98 | const newUser = await api.post('/users', JSON.stringify({ name: 'John' })); |
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.
| Feature | Fetch API | XMLHttpRequest |
|---|---|---|
| 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 Support | All modern browsers | Universal (legacy + modern) |
| Synchronous Mode | ✗ (async only) | ✓ (deprecated, avoid) |
| 1 | // XHR equivalent of a fetch call |
| 2 | function 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 |
| 27 | async 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
| 1 | // Production-ready fetch client |
| 2 | class 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 | |
| 45 | class 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 | |
| 54 | export const api = new APIClient(import.meta.env.VITE_API_URL); |
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.