The JavaScript Fetch API and Network Requests: Modern HTTP from the Browser
fetch is JavaScript's modern way to make HTTP requests. It replaced XMLHttpRequest (the old XHR from the jQuery $.ajax era) and shipped a cleaner, promise-based API that works the same in browsers, Node, Deno, Bun, and edge runtimes. This lesson covers fetch end to end: making requests, handling responses, error patterns, JSON and FormData bodies, AbortController for cancellation, and the modern idioms for timeouts and retries.
The basics #
const res = await fetch('/api/users');
const users = await res.json();
console.log(users);
Three things to notice:
fetchreturns a Promise —awaitworks directly.- The response is a
Responseobject, not the data itself. You parse the body separately. - The body parser (
res.json()) is also async — parsing happens as the body streams in.
The Response object #
A few useful properties and methods:
res.ok // true if status is 200–299
res.status // 200, 404, 500, ...
res.statusText // 'OK', 'Not Found', ...
res.headers // Headers object — res.headers.get('content-type')
res.url // final URL after redirects
res.redirected // true if any redirect happened
res.type // 'basic', 'cors', 'opaque', ...
Body parsers (each can only be called once per response):
res.json(); // parse as JSON
res.text(); // get as string
res.blob(); // get as a Blob (binary)
res.arrayBuffer(); // get as raw bytes
res.formData(); // parse multipart/form-data
The body is a stream — you can also read it manually with res.body.getReader() for advanced cases.
The big gotcha: fetch doesn't reject on HTTP errors #
The one thing that catches everyone the first time: fetch only rejects on network failures (DNS, offline, CORS). A 404 or 500 response is still a resolved promise.
const res = await fetch('/api/nonexistent');
console.log(res.ok); // false
console.log(res.status); // 404
// No error thrown — you have to check res.ok yourself
The idiomatic fix:
async function fetchJson(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}: ${url}`);
}
return res.json();
}
Wrap fetch in a thin helper like this in every codebase. Skipping the res.ok check is the most common fetch bug in production.
Making non-GET requests #
Pass a second argument with options:
const res = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ name: 'Ada' }),
});
Key points:
method—'POST','PUT','PATCH','DELETE', etc. Defaults to'GET'.headers— a plain object or aHeadersinstance.body— string,FormData,Blob,URLSearchParams,ReadableStream, orArrayBuffer.
JSON bodies #
fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
You must set Content-Type explicitly. fetch doesn't know to do it for plain string bodies.
FormData bodies #
fetch('/upload', {
method: 'POST',
body: formData, // do NOT set Content-Type yourself
});
With FormData, fetch sets Content-Type: multipart/form-data; boundary=... for you. Setting it manually breaks the boundary.
URL-encoded bodies #
const params = new URLSearchParams({ q: 'JavaScript', page: 2 });
fetch('/api/search', {
method: 'POST',
body: params, // Content-Type set to application/x-www-form-urlencoded
});
Good for traditional form-submission-style endpoints.
Query parameters: URLSearchParams #
For GET requests with query strings, build them with URLSearchParams:
const params = new URLSearchParams({ q: 'react', page: 1, per_page: 10 });
const res = await fetch(`/api/search?${params}`);
Or use the URL API directly:
const url = new URL('/api/search', window.location.origin);
url.searchParams.set('q', 'react');
url.searchParams.set('page', '1');
const res = await fetch(url);
Both handle URL-encoding correctly. Manual string concatenation (?q= + encodeURIComponent(q) + …) is error-prone — use the helpers.
Cancellation: AbortController #
Fetches don't time out by default — they hang as long as the connection is alive. Use AbortController to cancel:
const controller = new AbortController();
fetch('/api/users', { signal: controller.signal })
.then(res => res.json())
.then(users => render(users))
.catch(err => {
if (err.name === 'AbortError') {
console.log('Cancelled');
} else {
throw err;
}
});
// Later — cancel
controller.abort();
The canonical use cases:
- Component unmount — the user navigated away; abort the in-flight request.
- Search-as-you-type — abort the previous request when a new keystroke fires a new one.
- Timeouts — abort after N milliseconds (shown below).
Timeout helper #
There's no built-in timeout option on fetch. The standard pattern uses AbortSignal.timeout (a modern shorthand):
fetch('/api/slow', { signal: AbortSignal.timeout(5000) })
.catch(err => {
if (err.name === 'TimeoutError') console.log('Slow API');
});
Or roll your own:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, { signal: controller.signal });
return res.json();
} finally {
clearTimeout(timer);
}
CORS in 60 seconds #
Browsers enforce a same-origin policy. If your page is on https://app.example.com and you fetch('https://api.example.com/users'), the browser sends the request, then checks the response headers before letting your JS read it.
If the server doesn't include Access-Control-Allow-Origin permitting your origin, the fetch rejects with a CORS error. The request DID go through — the server saw it — but the response is hidden from your code.
Solutions:
- Server adds CORS headers for your origin (the right answer for APIs you control).
- Proxy through your own server (the right answer for third-party APIs).
- Use a server-side fetch (Next.js API routes, Node.js, etc.) instead of calling the third-party API from the browser.
Note that CORS does not apply on the server side — Node, Deno, Bun, and edge runtimes don't enforce CORS. Only browsers.
Streaming responses #
For large responses, read the body as a stream instead of waiting for it all:
const res = await fetch('/api/large-export');
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log(decoder.decode(value));
}
Useful for log streams, server-sent events on top of fetch, AI completion streams, etc.
For JSON lines (NDJSON, JSONL), parse each line as it arrives.
Retries with exponential backoff #
A common pattern, in 15 lines:
async function fetchWithRetry(url, options = {}, retries = 3) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const res = await fetch(url, options);
if (res.ok) return res;
if (res.status < 500) return res; // don't retry client errors
} catch (err) {
if (attempt === retries - 1) throw err;
}
await new Promise(r => setTimeout(r, 2 ** attempt * 500));
}
}
500ms, 1s, 2s backoffs. Doesn't retry 4xx (which would never succeed). For production, add jitter (random offset to avoid thundering herd) and a max delay.
Authorization #
Three common patterns:
1. Bearer token in header #
fetch('/api/me', {
headers: { 'Authorization': `Bearer ${token}` },
});
Standard for OAuth, JWT, API keys. Stored typically in memory or localStorage (with the XSS caveats — see Lesson 7.5).
2. Cookies (session auth) #
For cross-origin requests, cookies aren't sent by default:
fetch('/api/me', { credentials: 'include' });
For same-origin, cookies are sent automatically. credentials: 'include' lets you opt into sending them cross-origin (also requires the server to send Access-Control-Allow-Credentials: true).
3. Per-request API key #
fetch(`/api/data?key=${apiKey}`); // worst — leaks via logs and referrer headers
fetch('/api/data', { headers: { 'X-API-Key': apiKey } }); // better
A complete request helper #
Real codebases wrap fetch in a tiny helper that handles the boilerplate:
async function api(path, { method = 'GET', body, headers = {}, signal } = {}) {
const res = await fetch(`/api${path}`, {
method,
headers: {
'Accept': 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
signal,
});
if (!res.ok) {
const err = new Error(`${res.status} ${res.statusText}`);
err.status = res.status;
try { err.body = await res.json(); } catch {}
throw err;
}
return res.status === 204 ? null : res.json();
}
// Usage
const users = await api('/users');
const me = await api('/users', { method: 'POST', body: { name: 'Ada' } });
One file like this typically replaces what used to be axios in many codebases.
A summary #
fetchis promise-based.awaitit.fetchdoes not reject on HTTP errors. Always checkres.ok.- Parse the body once with
.json(),.text(),.blob(), etc. - Use
URLSearchParamsfor query strings,FormDatafor multipart,JSON.stringifyfor JSON. AbortControllerfor cancellation, timeouts, and search-as-you-type.- CORS is browser-enforced; servers need
Access-Control-*headers. - Wrap fetch in a helper that handles errors, JSON, and the standard headers in one place.
What's next #
Lesson 7.5 covers localStorage and sessionStorage — the browser's built-in key/value stores, their limits, the persistence model, and when to reach for IndexedDB instead. That completes Module 7 and the entire JavaScript Tutorial path.
Try it yourself #
The res.ok check is the single most missed step in fetch code. See how many people get this wrong:
try {
const res = await fetch('/api/nope');
console.log('success');
} catch (e) {
console.log('error:', e.message);
}browser_sandboxIt prints success. fetch does not reject on HTTP errors — only on network failures. A 404 is a successful HTTP transaction (the server responded), so the promise resolves. To make this catch 404s, you have to throw when !res.ok:if (!res.ok) throw new Error(res.statusText);Forgetting this turns server errors into silent success — one of the most common bugs in fetch code.
Know this pattern and you avoid the most common production bug in fetch-based code.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


