cheat sheet
Fetch API
The browser and Node 18+ built-in HTTP client that returns Promises. Covers GET/POST/PUT/DELETE, headers, error handling, streaming, AbortController, uploads, credentials, and CORS.
Fetch API
What it is
The Fetch API is the browser (and Node 18+) built-in HTTP client that returns Promises. It replaced the older XMLHttpRequest API with a cleaner, Promise-based interface, and is now the cross-runtime standard — the exact same call signature works in browsers, Node, Bun, Deno, Cloudflare Workers, and edge runtimes. In Node.js, fetch became globally available without imports starting in Node 18 (enabled by default in Node 21+) — backed by undici under the hood.
The spec consists of three core classes: Request (the input), Response (the output), and Headers (a case-insensitive multimap shared by both). The fetch(input, init) function is the convenience facade that wires them together. Reach for it for any HTTP call where you don't need interceptors or upload-progress events; for those, layer something like ky or axios on top, or use the Node dispatcher hook described below.
Runtime support matrix
| Runtime | fetch available | Body streaming | dispatcher / proxy |
|---|---|---|---|
| Browsers (evergreen) | Yes | Yes | No (use service worker) |
| Node 18.x | Experimental | Yes | Yes — via undici |
| Node 21+ | Stable | Yes | Yes — via undici |
| Bun | Yes (stable) | Yes | Limited (Bun-specific options) |
| Deno | Yes (stable) | Yes | Yes — Deno.createHttpClient |
| Cloudflare Workers | Yes | Yes | No (platform-managed) |
The Request object
A Request represents the input to a fetch — URL, method, headers, body, signal, and credentials policy bundled together. You rarely build one explicitly: passing a string URL plus an init object to fetch() constructs one internally. Constructing one explicitly is useful for middleware that needs to inspect or mutate requests before sending.
const req = new Request("https://api.example.com/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Alice" }),
});
console.log(req.method); // 'POST'
console.log(req.url); // 'https://api.example.com/users'
console.log(req.headers.get("content-type")); // 'application/json'
// Pass a Request directly to fetch
const response = await fetch(req);
A Request (like a Response) has a one-shot body. To re-use it — for example, in a retry loop — clone before consuming:
const cloned = req.clone(); // safe to consume the body twice
The Response object
A Response represents the result of a fetch — status, headers, body, and the URL after redirects. You can also construct one yourself, which is essential when writing Service Workers, Cloudflare Workers, or unit-testing fetch consumers.
// Construct a Response manually
const fake = new Response(JSON.stringify({ id: 1 }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
console.log(await fake.json()); // { id: 1 }
// Useful constants
Response.error(); // returns a network-error Response
Response.redirect("/new", 302); // returns a redirect Response
Response.json({ ok: true }); // shortcut (Node 21+, Bun, Deno)
Basic GET — reading the response
const response = await fetch("https://api.example.com/users");
const users = await response.json();
console.log(users);
Output:
[
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]
Body methods — five ways to read a body
A Response body is a ReadableStream that decodes into one of five eager helpers. Each method drains the stream and returns a Promise; you can only call one of them per response. The choice depends on the content type and how you want to process it.
// Response formats
const json = await response.json(); // parse body as JSON
const text = await response.text(); // raw text string
const blob = await response.blob(); // binary Blob (images, etc.)
const buffer = await response.arrayBuffer(); // raw ArrayBuffer
const form = await response.formData(); // FormData (multipart responses)
| Method | Returns | Use for |
|---|---|---|
.json() | Parsed JS value | REST APIs |
.text() | string | HTML, plaintext, CSV, debug logs |
.blob() | Blob | Images, PDFs, any binary you'll hand to the DOM |
.arrayBuffer() | ArrayBuffer | Binary parsing, hashing, WebAssembly modules |
.formData() | FormData | Multipart responses (rare — but symmetric with uploads) |
The body can only be consumed once. Calling
.json()after.text()on the same response throwsTypeError: Body has already been consumed. Clone the response first if you need to read it twice:response.clone().
const response = await fetch("/api/data");
const copy = response.clone();
const textVersion = await copy.text(); // log it
const parsedVersion = await response.json(); // use it
Empty body (e.g. 204 No Content) — call .text() and you get ""; call .json() and it throws. Always guard:
const res = await fetch(url, { method: "DELETE" });
const data = res.status === 204 ? null : await res.json();
POST with a JSON body
const response = await fetch("https://api.example.com/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer eyJhbGciOiJIUzI1NiJ9...",
},
body: JSON.stringify({ name: "Carol", role: "admin" }),
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const created = await response.json();
console.log(created);
Output:
{ id: 3, name: 'Carol', role: 'admin', createdAt: '2026-04-26T10:00:00Z' }
PUT, PATCH, DELETE
// PUT — full replacement
await fetch(`https://api.example.com/users/3`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Carol", role: "moderator" }),
});
// PATCH — partial update
await fetch(`https://api.example.com/users/3`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: "moderator" }),
});
// DELETE
const res = await fetch(`https://api.example.com/users/3`, {
method: "DELETE",
});
console.log(res.status); // 204
Headers
The Headers class provides a case-insensitive map of HTTP header name/value pairs with get, set, append, and has methods. In practice, passing a plain object to headers: is simpler and more common; the Headers instance is mainly useful when you need to inspect or iterate response headers.
// Using new Headers()
const headers = new Headers();
headers.append("Content-Type", "application/json");
headers.append("X-Request-ID", crypto.randomUUID());
// Plain object shorthand (most common)
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
});
// Reading response headers
console.log(response.headers.get("content-type"));
console.log(response.headers.get("x-ratelimit-remaining"));
// Iterate all response headers
for (const [key, value] of response.headers) {
console.log(`${key}: ${value}`);
}
Reading response metadata
The Response object exposes status code, a boolean ok flag (true for 2xx), final URL (after redirects), and whether a redirect occurred. Check these before consuming the body — fetch only rejects on network failure, not on 4xx/5xx.
const response = await fetch("https://api.example.com/data");
console.log(response.ok); // true if status 200–299
console.log(response.status); // 200, 404, 500, …
console.log(response.statusText); // "OK", "Not Found", …
console.log(response.url); // final URL after redirects
console.log(response.redirected); // true if a redirect occurred
console.log(response.type); // "basic" | "cors" | "opaque"
Error handling
fetch() only rejects its Promise on network errors (no connection, DNS failure, etc.). An HTTP 4xx or 5xx response is still a fulfilled Promise — you must check response.ok manually.
async function getUser(id) {
let response;
try {
response = await fetch(`/api/users/${id}`);
} catch (networkErr) {
// DNS failure, offline, etc.
throw new Error(`Network error: ${networkErr.message}`);
}
if (!response.ok) {
// 404, 500, etc. — fetch does NOT reject here
const body = await response.text();
throw new Error(`HTTP ${response.status}: ${body}`);
}
return response.json();
}
Streaming responses
For large payloads (file downloads, server-sent data) you can read the body chunk-by-chunk via response.body, which is a ReadableStream. Streaming avoids buffering the entire response in memory and lets you start processing earlier — critical for AI token streams, log tails, and multi-GB downloads. See node:stream for the Node-native stream API that interoperates with this.
Reader API
const response = await fetch("https://example.com/large-file.csv");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.length;
console.log(`Received ${received} bytes`);
process.stdout.write(decoder.decode(value, { stream: true }));
}
Output:
Received 16384 bytes
Received 32768 bytes
Received 49152 bytes
…
Async iteration (Node 21+, Bun, Deno)
response.body is async-iterable in modern runtimes, so a for await loop is the cleanest way to consume it.
const response = await fetch("https://example.com/large-file.csv");
const decoder = new TextDecoder();
let received = 0;
for await (const chunk of response.body) {
received += chunk.length;
process.stdout.write(decoder.decode(chunk, { stream: true }));
}
console.log(`\nTotal: ${received} bytes`);
Output:
…
Total: 524288 bytes
Streaming JSON / NDJSON
Server-sent events and AI streaming endpoints typically respond in NDJSON (one JSON object per line). Combine a stream with line splitting:
const response = await fetch("https://api.example.com/events");
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of response.body) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop(); // last partial line stays in buffer
for (const line of lines) {
if (line.trim()) console.log(JSON.parse(line));
}
}
Output:
{ type: 'event', id: 1 }
{ type: 'event', id: 2 }
{ type: 'event', id: 3 }
Streaming request bodies (uploads)
You can also stream a body into a request — useful for piping files to S3-compatible APIs without buffering. Requires Node 21+ or modern browsers behind an HTTPS context.
import { createReadStream } from "node:fs";
import { Readable } from "node:stream";
await fetch("https://api.example.com/upload", {
method: "POST",
body: Readable.toWeb(createReadStream("./large.bin")),
duplex: "half", // required when streaming a body
headers: { "Content-Type": "application/octet-stream" },
});
Output: (none — exits 0 on success)
AbortController — timeouts and cancellation
Manual abort
const controller = new AbortController();
// Cancel after 5 seconds
const timer = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch("/api/slow-endpoint", {
signal: controller.signal,
});
clearTimeout(timer);
return response.json();
} catch (err) {
if (err.name === "AbortError") {
console.log("Request was aborted");
} else {
throw err;
}
}
AbortSignal.timeout() — Node 17.3+ / modern browsers
// Cleaner: built-in timeout signal (no manual setTimeout cleanup needed)
const response = await fetch("/api/data", {
signal: AbortSignal.timeout(5000), // aborts after 5 s
});
Cancel on user action
let controller = new AbortController();
searchInput.addEventListener("input", async (e) => {
controller.abort(); // cancel previous in-flight request
controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${e.target.value}`, {
signal: controller.signal,
});
renderResults(await res.json());
} catch (err) {
if (err.name !== "AbortError") throw err;
}
});
Uploading files and blobs
Pass a FormData object as the body to send multipart/form-data — the browser sets the correct Content-Type header with the boundary automatically, so do not set it manually. For binary uploads without form fields, pass a Blob or ArrayBuffer directly.
// Upload a File from an <input type="file">
const fileInput = document.querySelector("#avatar");
const file = fileInput.files[0];
const formData = new FormData();
formData.append("avatar", file);
formData.append("userId", "42");
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
// Do NOT set Content-Type manually — the browser sets it with the boundary
});
// Upload a raw Blob
const blob = new Blob([JSON.stringify({ key: "val" })], {
type: "application/json",
});
await fetch("/api/raw", { method: "PUT", body: blob });
Cookies and credentials
By default, fetch does not send cookies or HTTP auth headers.
// same-origin only: send cookies on same-origin requests
fetch("/api/me", { credentials: "same-origin" });
// cross-origin: send cookies and auth headers to any origin
fetch("https://api.other.com/me", { credentials: "include" });
// never send credentials (default)
fetch(url, { credentials: "omit" });
For
credentials: "include"to work cross-origin, the server must respond withAccess-Control-Allow-Origin: <your-origin>(not*) andAccess-Control-Allow-Credentials: true.
CORS considerations
Cross-Origin Resource Sharing is enforced by the browser (not Node.js, Bun, or Deno when running server-side). The browser sandbox blocks scripts on origin A from reading responses from origin B unless the server at B explicitly opts in via response headers. A few key points:
- Simple requests (GET/POST with safe headers) get a preflight-free CORS check.
- Requests with custom headers (like
Authorization) trigger anOPTIONSpreflight. - The server must respond with the appropriate
Access-Control-Allow-*headers. - If the server does not cooperate, the browser blocks the response (opaque response). The
fetch()call still resolves, butresponse.type === "opaque"and you cannot read the body.
Preflight triggers
A request is considered "non-simple" (and triggers an OPTIONS preflight) when any of the following are true:
| Condition | Example |
|---|---|
| Method other than GET/HEAD/POST | PUT, PATCH, DELETE |
Content-Type outside the three "simple" types | application/json, anything but text/plain, application/x-www-form-urlencoded, multipart/form-data |
| Any custom header | Authorization, X-API-Key, X-Request-ID |
ReadableStream body | Any streamed upload |
Required response headers
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Access-Control-Allow-Credentials: true # only if credentials: 'include'
Inspecting CORS from the client
const response = await fetch("https://api.other.com/me", {
mode: "cors", // default
credentials: "include",
});
console.log(response.type); // 'cors' | 'basic' | 'opaque' | 'error'
response.type | Meaning |
|---|---|
basic | Same-origin request — full access |
cors | Cross-origin, server allowed — full access |
opaque | mode: 'no-cors' was set — body unreadable, status 0 |
opaqueredirect | Redirected with redirect: 'manual' |
error | Network failure / blocked by CORS |
fetch vs node-fetch vs axios vs got vs ky
| Feature | native fetch | node-fetch | axios | got | ky |
|---|---|---|---|---|---|
| Available in browser | Yes | No (Node only) | Yes (adapter) | No | Yes |
| Available in Node | v18+ (unflagged v21+) | v2/v3 | Yes | Yes | Yes |
| Promise-based | Yes | Yes | Yes | Yes | Yes |
| Automatic JSON parse | Manual .json() | Manual .json() | Automatic | Automatic | Automatic |
| Rejects on 4xx/5xx | No (manual check) | No (manual check) | Yes (throws) | Yes (throws) | Yes (throws) |
| Request/response interceptors | No | No | Yes | Hooks | Hooks |
| Upload progress | No (streams only) | No | Yes | Yes | No |
| Built-in retry | No (DIY) | No | No (plugin) | Yes | Yes |
| Built-in timeout | AbortSignal.timeout() | Similar | timeout option | Yes | Yes |
| Bundle size | 0 (built-in) | ~30 kB | ~30 kB | Node only | ~3 kB |
Recommendation: use native fetch for new projects. Add ky if you want a tiny wrapper with retry/hooks built in; add axios only if you need interceptors or upload-progress tracking; reach for got if you're Node-only and want the richest feature set.
Node-specific: custom dispatcher (undici)
Under the hood, Node's fetch is powered by the undici HTTP client. The dispatcher option (Node 18.5+) lets you swap in a custom undici Agent to control connection pools, configure HTTP proxies, pin TLS, or set per-request timeouts. This is the equivalent of Python's httpx.Client(transport=...).
import { Agent } from "undici";
const agent = new Agent({
connections: 50, // max sockets per origin (default 10)
pipelining: 1, // HTTP/1.1 pipelining (0 to disable)
keepAliveTimeout: 10_000,
connect: { rejectUnauthorized: false }, // skip TLS verify (testing only)
});
const response = await fetch("https://internal.example.com/api", {
dispatcher: agent,
});
Output: (none — exits 0 on success)
HTTP/HTTPS proxy
import { ProxyAgent } from "undici";
const proxyAgent = new ProxyAgent("http://proxy.corp:8080");
const response = await fetch("https://api.example.com", {
dispatcher: proxyAgent,
});
console.log(response.status);
Output:
200
Setting a global dispatcher
import { setGlobalDispatcher, Agent } from "undici";
setGlobalDispatcher(new Agent({ connections: 100 }));
// All subsequent fetch() calls use this dispatcher
Bun and Deno quirks
fetch is a shared standard, but each runtime has subtle differences worth knowing.
Bun
// Bun-specific options on the second arg
const response = await fetch("https://api.example.com", {
verbose: true, // log low-level transport (Bun-only)
proxy: "http://localhost:8888", // Bun-only shorthand
tls: { rejectUnauthorized: false },
});
Output: (none — exits 0 on success)
- Bun's
fetchdecompressesbr,gzip,deflate, andzstdautomatically. Bun.file()returns aBlobthat streams from disk — pass it asbody:directly to upload.- HTTP/2 is enabled by default; pass
protocol: "http1"to force HTTP/1.1.
Deno
// Deno-specific HttpClient with custom CA
const client = Deno.createHttpClient({
caCerts: [await Deno.readTextFile("./internal-ca.pem")],
});
const response = await fetch("https://internal.example.com", { client });
client.close();
Output: (none — exits 0 on success)
- Deno requires
--allow-netto make outbound calls. - Cleanup
client.close()is required, or the runtime will warn about resource leaks at shutdown.
Common pitfalls
- Treating 4xx/5xx as a thrown error —
fetchonly rejects on network failure. Always checkresponse.ok(or use a wrapper that throws for you, likeky). - Consuming a body twice — once you call
.json(), the stream is drained.clone()before the first read if you need a second pass. - Setting
Content-Typeon aFormDataupload — the browser/runtime sets the multipart boundary header automatically. If you set it manually, the boundary is missing and the server returns 400. - Forgetting
credentials: 'include'— same-origin requests send cookies by default, but cross-origin requests do not. CORS-credentialed flows need both the client flag and matching server headers. - Mixing
AbortSignal.timeout()with an outerAbortController— only one signal can be passed. UseAbortSignal.any([sig1, sig2])(Node 20+, modern browsers) to combine them. - Sending a stream body without
duplex: "half"— Node throwsRequestInit: duplex option is required when sending a body as a stream. Always set it for upload streams. - Mutating headers after construction —
Request.headersis a liveHeadersobject, but once a request has been sent it cannot be re-sent. Build a freshRequestper attempt. - Catching
AbortErroras a network failure — abort errors share the same rejection path as network errors. Distinguish viaerr.name === "AbortError"before retrying. - Trusting
response.urlfor security — after redirects,response.urlshows the final URL but the originalrequest.urlis gone. If you need to enforce an origin, setredirect: "manual"and inspectLocationyourself.
Real-world recipes
Retry with exponential backoff and jitter
A practical retry helper that backs off, jitters, and gives up on non-retryable status codes. Compose with AbortSignal.timeout() for a per-attempt cap.
async function fetchWithRetry(url, init = {}, { retries = 3, baseMs = 200 } = {}) {
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await fetch(url, {
...init,
signal: init.signal ?? AbortSignal.timeout(5000),
});
// Retry on 5xx and 429; bail on 4xx
if (response.status >= 500 || response.status === 429) {
throw new Error(`Retryable status: ${response.status}`);
}
return response;
} catch (err) {
lastErr = err;
if (attempt === retries) break;
const jitter = Math.random() * baseMs;
const wait = baseMs * 2 ** attempt + jitter;
await new Promise((r) => setTimeout(r, wait));
}
}
throw lastErr;
}
const res = await fetchWithRetry("https://api.example.com/flaky");
console.log(res.status);
Output:
200
Combining multiple abort signals
When you want both a global "user pressed cancel" controller and a per-request timeout, combine them with AbortSignal.any().
const userCancel = new AbortController();
const signal = AbortSignal.any([
userCancel.signal,
AbortSignal.timeout(10_000),
]);
const response = await fetch("/api/slow", { signal });
Output: (none — exits 0 on success)
Validating responses with Zod
Cross-runtime fetch returns unknown JSON; pair it with Zod to validate before consuming. This catches API drift at the boundary instead of letting bad data leak through your code.
import { z } from "zod";
const User = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
async function getUser(id) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return User.parse(await response.json()); // throws ZodError on schema mismatch
}
const alice = await getUser(1);
console.log(alice);
Output:
{ id: 1, name: 'Alice Dev', email: 'alice@example.com' }
Server-sent events (SSE) consumer
SSE is a streaming text protocol where each event is a data: ...\n\n block. Parse it via the streaming primitives above. Useful for LLM token streams and live dashboards.
async function* readSSE(url, init) {
const response = await fetch(url, init);
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of response.body) {
buffer += decoder.decode(chunk, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop();
for (const ev of events) {
const line = ev.split("\n").find((l) => l.startsWith("data: "));
if (line) yield JSON.parse(line.slice(6));
}
}
}
for await (const ev of readSSE("https://api.example.com/stream")) {
console.log(ev);
}
Output:
{ token: 'Hello' }
{ token: ' ' }
{ token: 'world' }
Concurrency-capped fan-out
When fetching N URLs in parallel, an unbounded Promise.all(urls.map(fetch)) saturates the network and the remote API. Cap concurrency with a small pool.
async function fetchAll(urls, { concurrency = 5 } = {}) {
const results = new Array(urls.length);
let i = 0;
async function worker() {
while (i < urls.length) {
const idx = i++;
const res = await fetch(urls[idx]);
results[idx] = await res.json();
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
return results;
}
const data = await fetchAll([
"https://api.example.com/1",
"https://api.example.com/2",
"https://api.example.com/3",
]);
console.log(data.length);
Output:
3
Conditional GET with ETag
Save bandwidth by sending the previous ETag so the server can return 304 Not Modified when the resource is unchanged.
let etag = null;
let cached = null;
async function getCachedJson(url) {
const response = await fetch(url, {
headers: etag ? { "If-None-Match": etag } : {},
});
if (response.status === 304) return cached;
etag = response.headers.get("etag");
cached = await response.json();
return cached;
}
Output: (none — exits 0 on success)
Download with progress
Combine a streaming body with the Content-Length header for a usable progress indicator.
const response = await fetch("https://example.com/large.bin");
const total = Number(response.headers.get("content-length")) || 0;
let received = 0;
const chunks = [];
for await (const chunk of response.body) {
chunks.push(chunk);
received += chunk.length;
if (total) {
const pct = ((received / total) * 100).toFixed(1);
process.stdout.write(`\r${pct}% (${received}/${total} bytes)`);
}
}
const blob = new Blob(chunks);
console.log(`\nDone: ${blob.size} bytes`);
Output:
100.0% (10485760/10485760 bytes)
Done: 10485760 bytes
Reusable fetch wrapper
async function apiFetch(path, options = {}) {
const base = "https://api.example.com";
const token = localStorage.getItem("token");
const response = await fetch(`${base}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
signal: options.signal ?? AbortSignal.timeout(10_000),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw Object.assign(new Error(error.message ?? "Request failed"), {
status: response.status,
});
}
// Return null for 204 No Content
if (response.status === 204) return null;
return response.json();
}
// Usage
const user = await apiFetch("/users/1");
const created = await apiFetch("/users", {
method: "POST",
body: JSON.stringify({ name: "Dave" }),
});