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.

Node.js 22 LTS

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" }),
});
  • body accepts a string, Blob, ArrayBuffer, FormData or URLSearchParams. Passing an object sends [object Object].
  • Set content-type yourself for JSON; the server parses based on that header, not on the body shape.
  • For FormData, do not set content-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: true for a small request fired during visibilitychange; 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, PUT or DELETE triggers an OPTIONS preflight; the server must answer it.
  • If curl works and the page fails, the difference is response headers, not the endpoint.
  • In development, a proxy (server.proxy in 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

  • fetch rejects on network failure only; check res.ok for everything else.
  • A body reads once — clone() when two consumers need it.
  • Deadlines come from AbortSignal.timeout, user cancellation from AbortController, and their errors differ.
  • Retry 429 and 5xx with exponential backoff plus jitter, and never blindly retry POST.
  • Browsers add CORS; Node needs absolute URLs and manual cookie handling.

Next: the DOM and events, where these responses finally reach a user.