Fundamentals · Networking & storage
Fetch & AJAX
⚡ TL;DR —
fetchis a two-stage promise: it resolves the moment the headers land, not the body, and — the trap everyone hits — it only rejects on a network failure, never on a404or500. You must checkresponse.okyourself.
🧠 Mental model
"AJAX" is the idea — update the page without a full reload by talking to the server in the background. XMLHttpRequest was the original transport; fetch is the modern one. Don't conflate the concept with the API.
The key mental shift for fetch: a response is a stream with a header, not a blob of data. The promise settles as soon as the status line and headers arrive; the body is a separate, lazily-consumed ReadableStream. That's why reading it (.json(), .text(), .blob()) is itself async — and why you can only do it once.
⚙️ How it actually works
fetch(url, init) returns a promise that:
- Resolves with a
Responseas soon as headers arrive — including for4xx/5xx. A500is a perfectly successful fetch as far as the promise is concerned. - Rejects only when the request never completed: DNS failure, offline, CORS block, or an
AbortControllerabort.
So the branch is: promise settled → check response.ok (status 200–299) → then parse the body.
Other mechanics that separate seniors:
- No timeout.
fetchwill wait forever. You add one withAbortSignal.timeout(ms)(or a manualAbortController). - Credentials default to
same-origin. Cross-origin cookies requirecredentials: 'include'and the server's CORS headers to allow it. - No upload progress. The one thing
XHRstill does thatfetchcan't (cleanly) —xhr.upload.onprogress. Download progress you can get by readingresponse.bodymanually. keepalive: truelets a request outlive the page (analytics onunload) — the modernnavigator.sendBeacon.
💻 Code
// ❌ The bug in 90% of tutorials: a 404 falls straight through to .then()
fetch('/api/user').then((r) => r.json()).then(use); // parses an error page as data
// ✅ Check ok, add a timeout, handle both failure modes.
async function getJSON(url) {
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`); // 4xx/5xx don't reject — do it yourself
return res.json(); // body read exactly once
}
try {
const user = await getJSON('/api/user');
} catch (err) {
// err is either a network/abort rejection OR our thrown HTTP error — handle both
}
// Manual cancellation (e.g. a stale search request):
const ctrl = new AbortController();
fetch('/search?q=' + q, { signal: ctrl.signal });
ctrl.abort(); // rejects with an AbortError
⚖️ Trade-offs
fetchvs a library (axios/ky). Libraries give you interceptors, auto-JSON, timeouts, retries, and a properHTTPErrorfor free.fetchis zero-dependency but you rebuild that ergonomics layer yourself. On a large app, a thin wrapper aroundfetchis usually the right call — not axios, not rawfetcheverywhere.- When NOT to use
fetch: if you genuinely need upload progress, reach forXMLHttpRequest— it's the one legitimate remaining use. - Streaming vs buffering.
.json()buffers the whole body. For huge or incremental responses (LLM token streams, large NDJSON), readresponse.bodywith a reader instead.
💣 Gotchas interviewers probe
fetchdoes not reject on HTTP errors. This is the question.response.ok/response.statusis on you.- The body can only be read once. Call
.json()twice and the second throws. Need it twice?res.clone()first. - No default timeout. A hung endpoint hangs your UI forever without an
AbortSignal. await res.json()throws on an empty body (e.g. a204 No Content) — guard it.- A "CORS error" isn't a fetch bug. The response is opaque by design; you can't read it or its status from JS. Fix it on the server.
Content-Typeisn't set for you on a POST — sendapplication/jsonyourself, or the server may mis-parse the body.
🎯 Say this in the interview
"The mental model I hold is that
fetchreturns a two-stage promise — it resolves when the headers arrive, and the body is a stream I consume separately, which is why parsing is async and single-use. The single biggest gotcha is that it only rejects on network-level failures, so a 404 or 500 still resolves — I always branch onresponse.okbefore parsing. Beyond that I'm careful about three things: there's no default timeout, so I attach anAbortSignal.timeout; cross-origin cookies needcredentials: 'include'plus server CORS; and if I need upload progress I fall back toXMLHttpRequest, which is the one thingfetchstill can't do. In a real codebase I'd wrap all of this in one small client rather than sprinkling rawfetchcalls around."
🔗 Go deeper
- MDN — Using Fetch — the canonical walkthrough, including streams.
- MDN — Response —
ok,status,clone, and the body methods. - MDN — AbortController — cancellation and timeouts done right.
- web.dev — Fetch API streaming — when to read
response.bodyyourself.