The Fetch API is the modern, promise-based way to make HTTP requests in the browser and Node.js (v18+). It replaces XMLHttpRequest and eliminates most Axios boilerplate. This guide covers every pattern you'll need — from basic GET to streaming responses.
Quick-reference table
| Task | Code |
|---|---|
| GET JSON | const data = await res.json() |
| POST JSON | fetch(url, { method: 'POST', body: JSON.stringify(d), headers: {'Content-Type':'application/json'} }) |
| Check success | if (!res.ok) throw new Error(res.statusText) |
| Send form | fetch(url, { method: 'POST', body: new FormData(form) }) |
| Set auth header | headers: { Authorization: 'Bearer ' + token } |
| Cancel request | signal: controller.signal |
| Timeout (5s) | AbortSignal.timeout(5000) |
| Read text | await res.text() |
| Read blob | await res.blob() |
| Read headers | res.headers.get('Content-Type') |
| Credentials (cookies) | credentials: 'include' |
| No cache | cache: 'no-store' |
Basic GET request
const res = await fetch('https://api.example.com/users');
if (!res.ok) {
throw new Error(`HTTP error: ${res.status}`);
}
const users = await res.json();
console.log(users);
Always check res.ok — fetch only rejects on network failure. A 404 or 500 still resolves; you must handle it manually.
POST — sending JSON
const res = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }),
});
if (!res.ok) {
const errorBody = await res.json().catch(() => null);
throw new Error(`POST failed: ${res.status} ${JSON.stringify(errorBody)}`);
}
const created = await res.json();
PUT and PATCH — updating resources
// Full replace
await fetch(`/api/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fullUser),
});
// Partial update
await fetch(`/api/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'new@example.com' }),
});
DELETE
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
// Many APIs return 204 No Content — don't try to .json() it
if (res.status !== 204) {
const body = await res.json();
}
Error handling
The two-layer error model is the most important thing to understand:
async function apiFetch(url, options = {}) {
let res;
// Layer 1 — network error (no internet, DNS failure, CORS block)
try {
res = await fetch(url, options);
} catch (networkError) {
throw new Error(`Network error: ${networkError.message}`);
}
// Layer 2 — HTTP error (4xx, 5xx)
if (!res.ok) {
let detail = '';
try {
const body = await res.json();
detail = body.message ?? JSON.stringify(body);
} catch {
detail = await res.text().catch(() => '');
}
throw new Error(`HTTP ${res.status}: ${detail}`);
}
return res;
}
// Usage
const res = await apiFetch('/api/users');
const users = await res.json();
Authentication headers
// Bearer token (JWT)
const res = await fetch('/api/profile', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
// Basic auth
const credentials = btoa(`${username}:${password}`);
const res2 = await fetch('/api/admin', {
headers: { Authorization: `Basic ${credentials}` },
});
// API key
const res3 = await fetch('/api/data', {
headers: { 'X-API-Key': apiKey },
});
Sending cookies (credentials)
By default fetch does not send cookies cross-origin. Use credentials:
// Same-origin: cookies sent automatically
const res = await fetch('/api/me');
// Cross-origin: must opt in (server must allow with Access-Control-Allow-Credentials)
const res2 = await fetch('https://api.example.com/me', {
credentials: 'include',
});
credentials value |
Cookies sent |
|---|---|
'omit' |
Never |
'same-origin' |
Same origin only (default) |
'include' |
Always (cross-origin too) |
FormData — file uploads and HTML forms
// Submit an HTML form
const form = document.querySelector('#upload-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const res = await fetch('/api/upload', {
method: 'POST',
body: new FormData(form), // No Content-Type header — browser sets multipart boundary
});
const result = await res.json();
});
// Manual FormData with a file input
const formData = new FormData();
formData.append('avatar', fileInput.files[0]);
formData.append('userId', '42');
const res = await fetch('/api/avatar', {
method: 'POST',
body: formData,
});
Do not set
Content-Type: multipart/form-datamanually — the browser must set it with the correct boundary string.
AbortController — cancelling requests
const controller = new AbortController();
// Cancel after 5 seconds
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch('/api/slow-endpoint', {
signal: controller.signal,
});
clearTimeout(timer);
const data = await res.json();
} catch (err) {
if (err.name === 'AbortError') {
console.log('Request was cancelled');
} else {
throw err;
}
}
Timeout shorthand (Node.js 18+ / modern browsers)
// Cleaner one-liner — no controller needed
const res = await fetch('/api/data', {
signal: AbortSignal.timeout(5000), // throws TimeoutError after 5s
});
Cancel on component unmount (React)
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort(); // cleanup
}, []);
Reading different response types
const res = await fetch('/api/resource');
// JSON
const json = await res.json();
// Plain text
const text = await res.text();
// Binary file (image, PDF)
const blob = await res.blob();
const url = URL.createObjectURL(blob);
// ArrayBuffer (WebAssembly, audio, crypto)
const buffer = await res.arrayBuffer();
// FormData (multipart response — rare)
const form = await res.formData();
You can only read the body once. Clone the response if you need it twice:
const res = await fetch('/api/data');
const resClone = res.clone();
const json = await res.json();
const text = await resClone.text(); // reads from clone
Request and Response objects
// Inspect the request before sending
const request = new Request('/api/users', {
method: 'GET',
headers: { Authorization: 'Bearer token' },
});
console.log(request.url); // 'https://example.com/api/users'
console.log(request.method); // 'GET'
const res = await fetch(request);
// Inspect the response
console.log(res.status); // 200
console.log(res.statusText); // 'OK'
console.log(res.ok); // true (200-299)
console.log(res.redirected); // true if followed a redirect
console.log(res.url); // final URL after redirects
console.log(res.type); // 'basic' | 'cors' | 'opaque'
// Response headers
res.headers.forEach((value, name) => {
console.log(`${name}: ${value}`);
});
console.log(res.headers.get('content-type'));
Cache control
// Always fetch fresh — never use cache
const res = await fetch('/api/data', { cache: 'no-store' });
// Use cache, revalidate in background
const res2 = await fetch('/api/config', { cache: 'stale-while-revalidate' });
// Force reload from server, update cache
const res3 = await fetch('/api/data', { cache: 'reload' });
cache value |
Behaviour |
|---|---|
'default' |
Normal browser cache logic |
'no-store' |
Never cache, always fetch fresh |
'no-cache' |
Revalidate with server even if cached |
'reload' |
Bypass cache for request, update cache with response |
'force-cache' |
Use cache even if stale |
'only-if-cached' |
Return cached or fail |
Parallel requests
// Run 3 requests at the same time
const [users, posts, comments] = await Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/comments').then(r => r.json()),
]);
// Race — use whichever resolves first
const fastest = await Promise.race([
fetch('/api/primary').then(r => r.json()),
fetch('/api/backup').then(r => r.json()),
]);
Retry with exponential backoff
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const res = await fetch(url, options);
if (res.ok) return res;
// Don't retry 4xx client errors
if (res.status >= 400 && res.status < 500) throw new Error(`Client error: ${res.status}`);
if (attempt === maxRetries) throw new Error(`Max retries exceeded: ${res.status}`);
} catch (err) {
if (attempt === maxRetries) throw err;
}
// Wait 2^attempt * 100ms (100ms, 200ms, 400ms)
await new Promise(resolve => setTimeout(resolve, 2 ** attempt * 100));
}
}
const res = await fetchWithRetry('/api/flaky-endpoint');
const data = await res.json();
TypeScript types
interface User {
id: number;
name: string;
email: string;
}
async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return res.json() as Promise<User>;
}
async function createUser(data: Omit<User, 'id'>): Promise<User> {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<User>;
}
Fetch vs Axios comparison
| Feature | Fetch | Axios |
|---|---|---|
| Built-in | ✅ (browser + Node 18+) | ❌ (npm install) |
| Auto JSON parse | ❌ (call .json()) |
✅ |
| Auto JSON body | ❌ (JSON.stringify + header) | ✅ |
| HTTP error throw | ❌ (check res.ok) |
✅ |
| Interceptors | ❌ (wrap manually) | ✅ |
| Progress events | ❌ (use ReadableStream) | ✅ |
| Request cancellation | ✅ (AbortController) | ✅ |
| Timeout | ✅ (AbortSignal.timeout) | ✅ |
| Older browser support | ✅ (polyfill needed for IE) | ✅ |
Use fetch when: you want zero dependencies, simple requests, or you're in Node.js 18+.
Use Axios when: you need interceptors, automatic retries, upload progress, or a consistent API across old and new browsers.
Streaming responses (Server-Sent Events / large files)
const res = await fetch('/api/stream');
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
process(chunk); // handle each chunk as it arrives
}
This pattern is essential for AI streaming APIs, large file downloads, or real-time data feeds.
6 common mistakes
| Mistake | Problem | Fix |
|---|---|---|
Not checking res.ok |
404/500 silently "succeeds" | Always if (!res.ok) throw |
Setting Content-Type on FormData |
Breaks multipart boundary | Let browser set it automatically |
Calling .json() on 204 No Content |
Throws SyntaxError | Check res.status !== 204 first |
Missing await on res.json() |
Gets a Promise, not data | const data = await res.json() |
| Reading body twice | Second read returns empty | Use res.clone() |
| No AbortController on slow requests | Requests hang indefinitely | Use AbortSignal.timeout() |
6 FAQ
Q: Why does fetch not throw on 404?
A: Fetch considers any completed HTTP exchange a success. Network-level failures (no connection, DNS error, CORS block) cause the promise to reject. HTTP error codes (4xx, 5xx) resolve the promise — you must check res.ok or res.status yourself.
Q: How do I send cookies cross-origin?
A: Set credentials: 'include'. The server must also respond with Access-Control-Allow-Credentials: true and specify an exact origin in Access-Control-Allow-Origin (not *).
Q: Can I use fetch in Node.js?
A: Yes, natively in Node.js 18+. For older versions, use node-fetch (npm install node-fetch) or undici.
Q: How do I upload a file with a progress bar?
A: Fetch doesn't support upload progress events. Use XMLHttpRequest with xhr.upload.onprogress, or Axios which wraps XHR. Download progress works via response.body.getReader().
Q: What's the difference between no-cache and no-store?
A: no-cache allows caching but requires revalidation with the server before using the cached copy. no-store prevents caching entirely — nothing is written to disk or memory.
Q: How do I handle redirects?
A: Fetch follows redirects automatically by default. Use redirect: 'manual' to intercept them, or redirect: 'error' to throw on any redirect. Check res.redirected and res.url to see if a redirect occurred.