JavaScript Fetch and APIs: Requests, JSON, Errors, Timeouts, and AbortController
Talking to APIs is the most common async work JavaScript does โ and fetch, the standard HTTP client in both browsers and Node (since v18), is deceptively simple. One function call, a promise back, JSON in hand. The deception is in what it doesnโt do: fetch has opinions about errors that surprise everyone, no built-in timeout, and several patterns you must supply yourself.
This guide covers real-world fetch: the res.ok trap, sending data, cancellation and timeouts, a production-shaped wrapper, and the questions (CORS! retries!) that arrive the first week you use it.
The Basic Shape
const res = await fetch("https://api.example.com/users/42");const user = await res.json();console.log(user.name);Two awaits, and both matter. The first resolves when response headers arrive โ you have status and headers, but not necessarily the body. The second (res.json()) reads the body stream to completion and parses it. Body readers come in flavors โ res.json(), res.text(), res.blob() (binary), res.formData() โ and a body can be read only once (itโs a stream, not a buffer; call res.clone() first if two consumers need it).
The Response object itself carries the metadata youโll route on:
res.status // 200, 404, 500...res.ok // true only for status 200โ299 โ the important oneres.headers.get("content-type")The Trap Everyone Hits: fetch Doesnโt Reject on HTTP Errors
Burn this in: a 404 or a 500 is a fulfilled fetch promise. From fetchโs perspective, the request succeeded โ a response came back; what the server said is your business. Fetch rejects only when no response happened at all: network down, DNS failure, CORS block, or abort.
// WRONG โ the happy path parses an error page as dataconst res = await fetch("/api/users/42");const user = await res.json(); // on a 404, this parses the error body // (or throws a confusing JSON parse error)
// RIGHT โ check res.ok yourself, every timeconst res = await fetch("/api/users/42");if (!res.ok) { throw new Error(`HTTP ${res.status} fetching user 42`);}const user = await res.json();So a robust call distinguishes three failure classes, each deserving different handling: network errors (fetch rejected โ often retryable, message says โfailed to fetchโ), HTTP errors (!res.ok โ route by status: 401 โ reauth, 404 โ not-found UI, 429 โ back off, 5xx โ maybe retry), and body/parse errors (res.json() threw โ the server sent malformed or non-JSON content). Collapsing all three into one generic โsomething failedโ catch is how apps end up with unhelpful error states; the wrapper at the end of this guide keeps them distinct.
Sending Data: POST, Headers, JSON
const res = await fetch("/api/orders", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, }, body: JSON.stringify({ sku: "B-1042", qty: 2 }),});The second argument configures everything: method, headers, body. The JSON ritual โ stringify the body, declare the content type โ is manual and forgetting either half produces classic symptoms (server sees [object Object], or parses nothing because the content-type said form data).
Other bodies youโll send: FormData for file uploads (donโt set Content-Type yourself โ the browser adds the multipart boundary), and URLSearchParams both for form-encoded bodies and for building query strings safely:
const params = new URLSearchParams({ q: "laptops", page: 2 });const res = await fetch(`/api/search?${params}`); // encoding handled for youHand-concatenating query strings with user input is both a bug source (unescaped &, spaces) and, server-side, an injection-adjacent habit โ URLSearchParams ends the whole category.
Timeouts and Cancellation: AbortController
Fetch waits forever by default โ a hung server means a hung request. The cancellation mechanism is AbortController:
// modern one-liner for timeoutsconst res = await fetch(url, { signal: AbortSignal.timeout(5000) });
// the general form โ cancel for ANY reasonconst controller = new AbortController();cancelButton.onclick = () => controller.abort();const res2 = await fetch(url, { signal: controller.signal });Abort surfaces as a rejection โ a TimeoutError or AbortError DOMException โ so your catch should distinguish โwe cancelledโ (usually silent) from genuine failures:
try { const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); // ...} catch (err) { if (err.name === "TimeoutError") return showRetryUI(); if (err.name === "AbortError") return; // user cancelled โ not an error throw err;}The pattern generalizes beyond fetch: the search-box use case โ abort the previous request when a new keystroke supersedes it โ is AbortController earning its keep, and it prevents the subtle bug where a slow old response arrives after a fast new one and overwrites fresher results.
A Production-Shaped Wrapper
Every real codebase wraps fetch once and imports the wrapper. Hereโs a compact one embodying this guide:
class ApiError extends Error { constructor(status, statusText, body) { super(`HTTP ${status} ${statusText}`); this.name = "ApiError"; this.status = status; this.body = body; }}
async function api(path, { method = "GET", body, timeoutMs = 8000, ...opts } = {}) { const res = await fetch(`${BASE_URL}${path}`, { method, headers: { ...(body !== undefined && { "Content-Type": "application/json" }), ...opts.headers, }, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs), ...opts, });
if (!res.ok) { const errBody = await res.json().catch(() => null); // error bodies are often JSON throw new ApiError(res.status, res.statusText, errBody); } return res.status === 204 ? null : res.json(); // no body on 204 No Content}
// usage reads the way call sites deserve:const user = await api(`/users/${id}`);await api("/orders", { method: "POST", body: { sku, qty } });What the wrapper centralizes โ exactly the things you donโt want re-decided at every call site: base URL, JSON ritual, res.ok enforcement, a typed error carrying status and server-provided detail (never discard the error body โ servers put the useful message there), timeout defaulting, and the 204 edge. Auth-header injection and 401-refresh logic slot in here too, once, when you need them. This 30-line shape is most of what people historically imported axios for; with fetch now universal, the hand-rolled wrapper is the common modern choice, with axios/ky earning their keep when you want interceptors and progress events pre-built.
Retries deserve one paragraph of design, not a loop slapped on: retry only retryable failures (network errors, 429, 502/503/504 โ never 400/401/404, and never non-idempotent POSTs unless the API is designed for it), with exponential backoff plus jitter (delay = base * 2^attempt + random), bounded attempts, and respect for Retry-After headers. Three lines of policy thinking that separate polite clients from accidental DDoS participants.
CORS: The Error That Isnโt Fetchโs Fault
The first week of API work reliably produces: โblocked by CORS policy: No โAccess-Control-Allow-Originโ header.โ Whatโs happening: browsers enforce the same-origin policy โ a page from app.example.com calling api.other.com requires the server to opt in via CORS response headers. Key facts that save hours:
- Itโs a browser security feature, not a bug โ and itโs enforced by the browser, which is why the same request works from curl/Postman/Node.
- Only the server can fix it properly โ by sending
Access-Control-Allow-Origin(and friends for methods/headers). If itโs your backend, configure it; if third-party, you proxy through your own server. - The failure surfaces as a fetch rejection with a deliberately vague message (the browser hides details from scripts); the real diagnosis lives in the DevTools console and Network tab โ including the preflight
OPTIONSrequest that non-simple requests (e.g., JSON POSTs with auth headers) trigger. - Dev-server proxies (Vite/webpack
proxyconfig) exist precisely to make local development not fight this.
Reading the Network Tab Like a Second Console
Half of API debugging happens in DevToolsโ Network panel, and knowing its vocabulary converts guesswork into diagnosis:
- Status column โ your
res.oklogic, visualized. Red rows are 4xx/5xx; a(failed)orCORS errorrow is the rejection class (no usable response at all). An(canceled)row is your own AbortController (or a navigation) โ often correct behavior, not a bug. - The request that isnโt yours โ an
OPTIONSrequest preceding your POST is the CORS preflight; its response headers (Access-Control-Allow-*) decide whether your real request ever launches. Debugging โmy POST never firesโ starts here. - Timing breakdown โ clicking a request shows queueing, DNS, connect, TTFB (server think-time), and download. โAPI is slowโ splits into which phase: a fat TTFB is the backendโs problem; long queueing suggests too many parallel requests; slow download means payload size.
- Payload and Response tabs โ what you actually sent (typos in JSON bodies hide here) and what actually came back (the HTML-error-page-parsed-as-JSON diagnosis takes five seconds when you look).
- Copy as fetch/cURL โ right-click any request to reproduce it outside your app; the fastest way to answer โis it my code or the API?โ
The habit worth building immediately: when a fetch misbehaves, open the Network tab before adding console.logs. The panel already recorded what happened; logging is you reconstructing it the slow way.
Frequently Asked Questions
When is fetch unavailable or different? Itโs standard in all browsers and Node 18+. Older Node needed node-fetch. Server-side fetch has no CORS (thatโs browser enforcement) and no cookies-by-default; browser fetch sends same-origin cookies but needs credentials: "include" for cross-origin ones.
How do I upload a file with progress? Fetch can upload FormData easily, but upload progress events arenโt exposed (download progress is, via reading res.body as a stream). For upload progress bars specifically, the older XMLHttpRequest or a library remains the pragmatic answer.
JSON.parse failed with Unexpected token '<' โ what happened? You parsed HTML โ almost always an error page or an SPA fallback served where JSON was expected (wrong path, expired session redirect). Log res.status and the first 100 chars of await res.text() when diagnosing; check content-type before parsing in strict wrappers.
Should the wrapper validate response shape? In typed projects, yes at trust boundaries โ a schema check (e.g., zod) converts โthe API changed and undefined propagated through six componentsโ into an immediate, located error. The coercion guideโs boundary-validation principle, applied to HTTP.
Parallel requests? Everything from the async/await guide applies verbatim: independent calls โ Promise.all(map(api)); polite bulk work โ limited concurrency; per-item outcomes โ allSettled.
Caching Headers: The Free Performance Layer
One paragraph of HTTP literacy that pays for itself: responses carry caching instructions your fetch silently obeys. Cache-Control: max-age=300 lets the browser serve the next five minutes of identical GETs from disk without any network; ETag + If-None-Match turns re-validation into a tiny 304 round-trip instead of a full payload. What this means for your code: repeated GETs to well-configured APIs are often cheaper than you think (resist hand-rolling caches before checking what HTTP already does), stale data mysteries sometimes trace to over-aggressive max-age rather than your logic, and fetch(url, { cache: "no-store" }) exists for the moments you genuinely need to bypass it all. Server-controlled caching plus your promise-level request deduplication covers most performance needs before any caching library enters the conversation.
Self-Check
// 1 โ what's wrong, and what happens on a 500?const data = await (await fetch("/api/stats")).json();
// 2 โ why might this show stale results?input.oninput = async () => { const res = await fetch(`/search?q=${input.value}`); render(await res.json());};
// 3 โ what TWO things are missing for a JSON POST?await fetch("/api/notes", { method: "POST", body: { text: "hi" } });
// 4 โ is retrying this request safe? Why/why not?await api("/payments", { method: "POST", body: { amountPaise, to } });Answers: (1) No res.ok check โ a 500โs HTML/JSON error body gets parsed as data (or throws a parse error pointing at the wrong culprit). (2) Race: a slow response for โlapโ can land after the fast one for โlaptopโ, overwriting fresher results โ abort the previous request via AbortController (and encode with URLSearchParams while youโre there). (3) JSON.stringify(body) and the Content-Type: application/json header. (4) Not blindly โ POSTs arenโt idempotent; a timeout might mean the payment succeeded but the response was lost, and a retry double-charges. Safe retries need an idempotency key supported by the API.
Next, the series shifts to the modern-syntax toolkit, starting with the methods youโll use hourly: Array Methods โ map, filter, reduce, and friends, plus the mutation traps that come with them.