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:
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.
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"); // trueBody types
fetch accepts several body kinds and sets sensible defaults for Content-Type.
string— sent as-is. SetContent-Typeyourself.URLSearchParams— sent asapplication/x-www-form-urlencoded.FormData— sent asmultipart/form-datawith a generated boundary. Do not set the header yourself or the boundary breaks.Blob/File— sent raw withContent-Typetaken from the blob.ArrayBuffer/ typed array — sent as raw bytes.ReadableStream— sent chunk-by-chunk (request streams, modern browsers,duplex: "half"required).
JSON POST
await fetch("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Tea", qty: 2 }),
});form-encoded POST
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.
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();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 withAccess-Control-Allow-Credentials: trueand a specificAccess-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.
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
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.
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 }));
}