The Fetch API is the modern, Promise-based way to make HTTP requests from the browser โ replacing the verbose, callback-based XMLHttpRequest. It is built into all modern browsers and is the foundation of every web application that communicates with a server: loading data, submitting forms, calling REST APIs, and uploading files. In this lesson you will master the fetch request lifecycle, all configuration options, reading different response types, sending JSON and FormData, handling errors correctly, and adding request cancellation with AbortController.
fetch Request and Response
| Property / Method | Description | Example |
|---|---|---|
fetch(url, options) |
Returns Promise<Response> | fetch('/api/users') |
response.ok |
Boolean โ status 200-299 | if (!res.ok) throw new Error(...) |
response.status |
HTTP status code number | 404, 200, 500 |
response.statusText |
Status message string | 'Not Found', 'OK' |
response.json() |
Promise<any> โ parse body as JSON | For JSON APIs |
response.text() |
Promise<string> โ body as text | For HTML or plain text responses |
response.blob() |
Promise<Blob> โ body as binary | For images, files, downloads |
response.formData() |
Promise<FormData> | For multipart responses |
response.headers.get(name) |
Header value or null | res.headers.get('content-type') |
response.clone() |
Clone response โ body can only be read once | Read body multiple times |
fetch Options
| Option | Type | Description |
|---|---|---|
method |
string | 'GET' (default), 'POST', 'PUT', 'PATCH', 'DELETE' |
headers |
object / Headers | { 'Content-Type': 'application/json', Authorization: ... } |
body |
string / FormData / Blob | Request body โ not for GET/HEAD |
signal |
AbortSignal | Cancel the request |
credentials |
string | 'same-origin' (default), 'include' (send cookies cross-origin), 'omit' |
cache |
string | 'default', 'no-store', 'reload', 'force-cache' |
mode |
string | 'cors' (default), 'no-cors', 'same-origin' |
Critical: fetch Does Not Reject on HTTP Errors
| Scenario | fetch behaviour | response.ok |
|---|---|---|
| 200 OK | Resolves | true |
| 404 Not Found | Resolves โ no rejection! | false |
| 500 Server Error | Resolves โ no rejection! | false |
| Network failure / CORS error | Rejects โ TypeError | N/A |
| AbortController.abort() | Rejects โ AbortError | N/A |
fetch: it only rejects on network errors (no connection, CORS block, DNS failure). HTTP error codes like 404 or 500 resolve the Promise โ you must check response.ok or response.status manually. Failing to do this means your code silently receives error responses as if they were successful ones.fetch that throws on HTTP errors automatically, adds base URL, attaches auth headers, and parses JSON: async function api(path, opts) { const res = await fetch(baseURL + path, { ...defaults, ...opts }); if (!res.ok) throw new Error(...); return res.json(); }. Centralising these concerns keeps every individual call clean and consistent.response.json() after already calling response.text() on the same response throws an error. If you need to read the body in multiple places โ for logging and parsing, for example โ use response.clone() first to create an independent copy, then read each clone separately.Basic Example
// โโ GET request โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function getUsers() {
const response = await fetch('/api/users');
// CRITICAL: check response.ok โ fetch does NOT reject on 4xx/5xx
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json(); // parse body as JSON
}
// โโ POST โ send JSON โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function createUser(userData) {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message ?? `HTTP ${response.status}`);
}
return response.json();
}
// โโ PUT / PATCH / DELETE โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function updateUser(id, changes) {
const res = await fetch(`/api/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
});
if (!res.ok) throw new Error(`Update failed: ${res.status}`);
return res.json();
}
async function deleteUser(id) {
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
return true;
}
// โโ File / FormData upload โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function uploadAvatar(userId, file) {
const formData = new FormData();
formData.append('avatar', file);
formData.append('userId', userId);
// DO NOT set Content-Type โ browser sets it with boundary automatically
const res = await fetch(`/api/users/${userId}/avatar`, {
method: 'POST',
body: formData,
});
if (!res.ok) throw new Error('Upload failed');
return res.json();
}
// โโ Download a file (blob) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function downloadReport(id) {
const res = await fetch(`/api/reports/${id}/download`);
if (!res.ok) throw new Error('Download failed');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const filename = res.headers.get('content-disposition')
?.match(/filename="(.+)"/)?.[1] ?? 'report.pdf';
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// โโ AbortController โ cancellation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function searchWithCancel(query) {
const controller = new AbortController();
// Auto-cancel after 5 seconds
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (err.name === 'AbortError') {
console.log('Request cancelled');
return null;
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
// โโ Live search โ cancel previous request when new one starts โโโโโโโโโโโโโ
let currentController = null;
searchInput.addEventListener('input', async (e) => {
const query = e.target.value.trim();
if (!query) { results.innerHTML = ''; return; }
// Cancel previous in-flight request
currentController?.abort();
currentController = new AbortController();
try {
const data = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: currentController.signal,
}).then(r => r.json());
renderResults(data);
} catch (err) {
if (err.name !== 'AbortError') showError(err);
}
});
// โโ Reading response headers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const res = await fetch('/api/data');
console.log(res.headers.get('x-total-count')); // pagination total
console.log(res.headers.get('x-rate-limit-remaining'));
How It Works
Step 1 โ fetch Returns a Promise of a Response Object
The Response object contains the HTTP status, headers, and a readable stream for the body. The body is NOT automatically parsed โ you choose how to read it by calling .json(), .text(), .blob(), or .arrayBuffer(). Each returns a Promise that resolves when the body is fully downloaded and parsed.
Step 2 โ Always Check response.ok
fetch resolves its Promise when a response arrives โ even a 404 or 500. response.ok is true for status codes 200-299 and false for everything else. Checking this and throwing if not OK is the most important fetch pattern โ without it, your application silently treats error responses as successful data.
Step 3 โ JSON Stringify for POST Bodies
When sending JSON, you must both stringify the body with JSON.stringify(data) and set the Content-Type: application/json header. Without the header, the server may not know how to parse the body. Without JSON.stringify, the body will be [object Object] โ a string representation of the object.
Step 4 โ AbortController Enables Cancellation
An AbortController creates an AbortSignal that can be passed to fetch. Calling controller.abort() cancels the request โ the fetch Promise rejects with an AbortError. This is essential for live search (cancel the previous request when a new query arrives) and timeout wrappers (abort if no response within N seconds).
Step 5 โ FormData Upload Sets Content-Type Automatically
When you pass a FormData object as the body, the browser automatically sets Content-Type: multipart/form-data with the correct boundary string. If you manually set Content-Type, the browser cannot add the boundary โ and the server cannot parse the multipart body. Always omit the Content-Type header for FormData requests.
Real-World Example: REST API Wrapper
// rest-api.js
async function apiFetch(path, { method = 'GET', body, headers = {}, signal } = {}) {
const token = localStorage.getItem('token');
const options = {
method,
signal,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers,
},
...(body ? { body: JSON.stringify(body) } : {}),
};
const response = await fetch(`/api${path}`, options);
// Parse body regardless of status (error details may be in body)
let data = null;
const ct = response.headers.get('content-type') ?? '';
if (ct.includes('application/json') && response.status !== 204) {
data = await response.json().catch(() => null);
}
if (!response.ok) {
const err = new Error(data?.message ?? `HTTP ${response.status}`);
err.status = response.status;
err.data = data;
// Auto-redirect on 401
if (response.status === 401) {
window.location.href = '/login';
}
throw err;
}
return data;
}
// Clean calling code โ no boilerplate
const users = await apiFetch('/users');
const newUser = await apiFetch('/users', { method: 'POST', body: { name: 'Alice' } });
const updated = await apiFetch(`/users/${id}`, { method: 'PATCH', body: { role: 'admin' } });
await apiFetch(`/users/${id}`, { method: 'DELETE' });
Common Mistakes
Mistake 1 โ Not checking response.ok
โ Wrong โ 404/500 treated as success:
const data = await fetch('/api/users/999').then(r => r.json());
// If 404: data = { error: 'Not found' } โ but code continues as if success
โ Correct โ always check ok:
const res = await fetch('/api/users/999');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
Mistake 2 โ Setting Content-Type on FormData
โ Wrong โ breaks multipart boundary:
fetch('/upload', {
method: 'POST',
headers: { 'Content-Type': 'multipart/form-data' }, // โ no boundary!
body: formData,
});
โ Correct โ omit Content-Type for FormData:
fetch('/upload', { method: 'POST', body: formData }); // browser sets it correctly
Mistake 3 โ Reading response body twice
โ Wrong โ body can only be consumed once:
const text = await response.text();
const json = await response.json(); // TypeError: body already used
โ Correct โ clone if you need to read twice:
const clone = response.clone();
const text = await response.text(); // log raw response
const json = await clone.json(); // also parse as JSON
Quick Reference
| Task | Code |
|---|---|
| GET JSON | const res = await fetch(url); if(!res.ok) throw...; return res.json(); |
| POST JSON | fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) }) |
| Upload file | fetch(url, { method:'POST', body: new FormData(form) }) |
| Cancel request | const c = new AbortController(); fetch(url, { signal: c.signal }); c.abort(); |
| Check error | if (!response.ok) throw new Error(...) |
| Read text | await response.text() |
| Read binary | await response.blob() |
| Read header | response.headers.get('x-total-count') |