JavaScript fetch and HTTP in Practice
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
fetch is now the HTTP client in both the browser and Node, which makes it worth learning properly instead of copying snippets. The API is small; the surprises are in the defaults.
The smallest request
const res = await fetch("https://api.example.com/items");
const data = await res.json();
fetch rejects only when the request fails at the network level: DNS failure, refused connection, a TLS problem, or an abort. A 404 or a 500 still resolves, so the status check is your job:
async function getJson(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText} for ${url}`);
}
return res.json();
}
Putting the URL in the message matters more than it looks: by the time the error reaches a log, the call site is gone.
Reading the response
res.status; // 200
res.ok; // true for 200–299
res.statusText; // "OK"
res.headers.get("content-type"); // "application/json; charset=utf-8"
res.url; // final URL after redirects
res.redirected; // true if a redirect happened
The body is a stream and can be consumed once:
await res.json(); // parse JSON
await res.text(); // raw string
await res.arrayBuffer(); // binary
await res.blob(); // Blob with a MIME type
Reading it twice throws TypeError: Body is unusable. Clone first if you need both:
const copy = res.clone();
console.log(await res.text());
const data = await copy.json();
JSON.parse inside res.json() throws SyntaxError on an HTML error page, so a 500 that returns an HTML body fails at a different line than the status check would have caught.
POST and headers
const res = await fetch("/api/items", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "Draft" }),
});
bodyaccepts a string,Blob,ArrayBuffer,FormDataorURLSearchParams. Passing an object sends[object Object].- Set
content-typeyourself for JSON; the server parses based on that header, not on the body shape. - For
FormData, do not setcontent-type: the browser has to add the multipart boundary. - Cookies require
credentials: "include"in the browser, and the server must allow that origin explicitly. - Use
keepalive: truefor a small request fired duringvisibilitychange; it survives the page going away, with a 64 KB body limit.
Timeouts and cancellation
There is no default timeout. A stalled connection keeps the promise pending forever, which in a server means a leaked request slot.
// fixed deadline
const res = await fetch("/api/slow", { signal: AbortSignal.timeout(5000) });
// user cancellation, plus a deadline
const controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]);
cancelButton.onclick = () => controller.abort();
try {
const res = await fetch("/api/search?q=go", { signal });
} catch (err) {
if (err.name === "TimeoutError") console.error("took too long");
else if (err.name === "AbortError") return; // the user asked for this
else throw err;
}
A timeout is a TimeoutError; an explicit abort is an AbortError. Treating both as “failed” hides the difference between a slow backend and a user who changed their mind.
AbortSignal.timeout also propagates into res.json(), so a large body that stalls mid-stream is covered by the same deadline.
Retry with backoff
Retry when the failure is likely transient: network errors, 429, 502, 503, 504. Do not retry 400, 401, 403 or 404 — repeating the request cannot change the answer.
async function fetchWithRetry(url, { retries = 3, baseDelay = 300 } = {}) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
const retryable = res.status === 429 || res.status >= 500;
if (res.ok || !retryable || attempt >= retries) return res;
const retryAfter = Number(res.headers.get("retry-after"));
const delay = Number.isFinite(retryAfter)
? retryAfter * 1000
: baseDelay * 2 ** attempt + Math.random() * 100;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
Retry-After is either seconds or an HTTP date; the Number check covers the common case, and the header always wins over your own schedule. The random component prevents every client from retrying in the same millisecond after an outage.
Only retry idempotent requests. A blind retry on POST /orders can create two orders; if you must retry, send an idempotency key and let the server deduplicate.
Several requests at once
const urls = ["/api/a", "/api/b", "/api/c"];
// all or nothing
const [a, b, c] = await Promise.all(urls.map(getJson));
// partial results are acceptable
const results = await Promise.allSettled(urls.map(getJson));
for (const r of results) {
if (r.status === "fulfilled") console.log(r.value);
else console.warn("skipped:", r.reason.message);
}
Cap the concurrency for anything beyond a handful of URLs — browsers allow about six connections per host, and servers rate limit by IP. The mapLimit helper from the previous chapter applies unchanged.
Streaming a response
res.body is a ReadableStream, which is what makes progress bars, large exports and server-sent events possible:
const res = await fetch("/api/export.ndjson");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) if (line) console.log(JSON.parse(line));
}
Two details: { stream: true } keeps multi-byte characters intact across chunk boundaries, and the trailing buffer must be flushed after the loop or you drop the last partial line.
For real SSE, use EventSource (or a small library) rather than splitting frames by hand — EventSource handles reconnection and event IDs for you.
CORS, and what it actually means
A browser request to another origin is limited by the same-origin policy. The console message is blocked by CORS policy, and it is the browser refusing to hand you a response that the server did send.
- The server must return
Access-Control-Allow-Origin;*cannot be combined with credentials. - A non-simple header,
PUTorDELETEtriggers anOPTIONSpreflight; the server must answer it. - If
curlworks and the page fails, the difference is response headers, not the endpoint. - In development, a proxy (
server.proxyin Vite) avoids the problem entirely; opening the API to*is the worse habit.
Node has no same-origin policy, so fetch there is not affected by CORS at all. It does still need an absolute URL.
Node versus the browser
| Browser | Node.js | |
|---|---|---|
| Relative URLs | resolved against the page | error: needs an absolute URL |
| Cookies | sent automatically for same-origin | set headers.cookie manually |
| CORS | enforced | not applicable |
| Redirects | followed | followed (redirect: "manual" changes it) |
| Proxy | browser and server settings | set a dispatcher, e.g. undici ProxyAgent |
In Node, fetch("/api/x") throws Failed to parse URL; write http://localhost:3000/api/x. To read Set-Cookie, use res.headers.getSetCookie() — the plain get joins multiple cookies into one unusable string.
Common mistakes
1. Treating a 404 as an exception
fetch resolves. Without a res.ok check, an error page is parsed as data and the failure surfaces three functions later.
2. Forgetting await before res.json()
You get a promise, not the payload, and the bug shows up as undefined property access.
3. Reading the body twice
res.json() then res.text() throws. Clone first, or design the code so only one consumer reads the body.
4. Sending JSON.stringify for a file upload
FormData is the right body for uploads, and setting content-type manually breaks the boundary.
5. Retrying everything
Retries belong on idempotent requests and transient statuses only. A retried POST is a duplicate order.
6. Trusting Failed to fetch
Browsers hide the reason for security. Log err.name and err.cause, and confirm in the Network panel whether the request left at all.
7. No abort on navigation
A request started on page A resolves after the user is on page B and overwrites newer state. Abort in the cleanup path of whatever started it.
Summary
fetchrejects on network failure only; checkres.okfor everything else.- A body reads once —
clone()when two consumers need it. - Deadlines come from
AbortSignal.timeout, user cancellation fromAbortController, and their errors differ. - Retry
429and5xxwith exponential backoff plus jitter, and never blindly retryPOST. - Browsers add CORS; Node needs absolute URLs and manual cookie handling.
Next: the DOM and events, where these responses finally reach a user.