Technology  /  JavaScript

๐ŸŸจ JavaScript 15 guides ยท updated 2026

Closures, the event loop, promises, and modern ES syntax โ€” the language of the web explained the way it actually behaves at runtime.

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 one
res.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 data
const 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 time
const 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 you

Hand-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 timeouts
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
// the general form โ€” cancel for ANY reason
const 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:


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:

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.