JavaScriptFetch in Depth (headers, CORS, streams)

fetch in depth

The basic fetch(url) call works without options for most GETs. Real apps need more — custom headers, credentials, JSON bodies, file uploads, CORS workarounds, request cancellation, and sometimes streaming. This page is the practical reference: how to shape requests, what the response gives you, how to use AbortController, and where CORS sits in the picture.

The request init object

Every option goes in the second argument to fetch. The common ones:

JS
fetch(url, {
  method: "POST",              // GET, HEAD, POST, PUT, PATCH, DELETE, ...
  headers: { "Content-Type": "application/json", "X-Trace": "42" },
  body: JSON.stringify(data),
  mode: "cors",                // "cors" | "no-cors" | "same-origin"
  credentials: "include",      // "omit" | "same-origin" | "include"
  cache: "default",            // "no-store" | "reload" | "no-cache" | ...
  redirect: "follow",          // "follow" | "error" | "manual"
  referrerPolicy: "no-referrer",
  signal: controller.signal,
});
Headers

Headers can be a plain object, a 2-D array, or a Headers instance. The Headers class gives you mutation helpers and case-insensitive lookup.

JS
const h = new Headers();
h.set("Accept", "application/json");
h.append("X-Tag", "a");
h.append("X-Tag", "b");
h.get("x-tag");      // "a, b" — case-insensitive
h.has("Accept");     // true
Forbidden headers
Some headers — `Host`, `Origin`, `Cookie`, `Content-Length`, anything starting with `Sec-` or `Proxy-` — are controlled by the browser and cannot be set from JavaScript. Attempts are silently dropped.
Body types

fetch accepts several body kinds and sets sensible defaults for Content-Type.

  • string — sent as-is. Set Content-Type yourself.

  • URLSearchParams — sent as application/x-www-form-urlencoded.

  • FormData — sent as multipart/form-data with a generated boundary. Do not set the header yourself or the boundary breaks.

  • Blob / File — sent raw with Content-Type taken from the blob.

  • ArrayBuffer / typed array — sent as raw bytes.

  • ReadableStream — sent chunk-by-chunk (request streams, modern browsers, duplex: "half" required).

JSON POST

JS
await fetch("/api/items", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Tea", qty: 2 }),
});

form-encoded POST

JS
const body = new URLSearchParams({ q: "hello", page: "2" });
await fetch("/search", { method: "POST", body });
The response

The promise resolves to a Response even on 4xx and 5xx. Check res.ok (true for 2xx) or res.status yourself — fetch only rejects on network errors.

JS
const res = await fetch(url);

res.ok;                 // true for 2xx
res.status;             // 200, 404, ...
res.statusText;         // "OK", "Not Found"
res.headers.get("X-Total"); // case-insensitive

const data = await res.json();   // throws if body is not JSON
const text = await res.text();
const blob = await res.blob();
const buf  = await res.arrayBuffer();
A body can only be read once
Each of those reader methods consumes the body. Calling both `res.json()` and `res.text()` on the same response throws. Use `res.clone()` if you need two passes.
credentials and cookies

Cookies are not sent on cross-origin requests by default. The credentials option controls this:

  • "omit" — never send cookies, ever.

  • "same-origin" (default) — send cookies only when origin matches.

  • "include" — send cookies even cross-origin. The server must allow it with Access-Control-Allow-Credentials: true and a specific Access-Control-Allow-Origin (not *).

cache and mode

cache selects the browser's HTTP cache strategy — usually you want the default. Two values worth knowing:

  • "no-store" — never read from or write to the HTTP cache. Use for sensitive responses.

  • "reload" — bypass cached responses, but still revalidate and store.

mode controls how cross-origin requests are made. "cors" is the default and gives you a full response object. "no-cors" returns an opaque response — status 0, no readable body. "same-origin" simply errors on cross-origin URLs.

CORS in one paragraph

When your page on a.com fetches b.com/api, the browser checks the response for an Access-Control-Allow-Origin header naming your origin. If it is missing or wrong, the browser refuses to expose the response to JavaScript. For methods other than GET / HEAD / POST or for custom headers, the browser first sends an OPTIONS preflight. The server must respond to that with the appropriate Allow-Methods and Allow-Headers.

AbortController for cancellation

Pass signal: controller.signal and call controller.abort() to cancel. Useful for typeahead search and component unmounts.

JS
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), 5000);

try {
  const res = await fetch("/slow", { signal: controller.signal });
  const data = await res.json();
} catch (err) {
  if (err.name === "AbortError") console.log("cancelled");
  else throw err;
} finally {
  clearTimeout(id);
}

cancel previous request on every keystroke

JS
let active;

input.addEventListener("input", async () => {
  active?.abort();
  active = new AbortController();
  try {
    const res = await fetch(`/search?q=${input.value}`, { signal: active.signal });
    render(await res.json());
  } catch {}
});
Streaming responses

A response body is a ReadableStream. You can read it chunk-by-chunk to start showing data before the whole response arrives — handy for logs, large JSON, or AI streams.

JS
const res = await fetch("/stream");
const reader = res.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  output.append(decoder.decode(value, { stream: true }));
}
Timeout helper
Modern browsers support `AbortSignal.timeout(ms)` directly — no controller required: `fetch(url, { signal: AbortSignal.timeout(5000) })`.