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

Runtimefetch availableBody streamingdispatcher / proxy
Browsers (evergreen)YesYesNo (use service worker)
Node 18.xExperimentalYesYes — via undici
Node 21+StableYesYes — via undici
BunYes (stable)YesLimited (Bun-specific options)
DenoYes (stable)YesYes — Deno.createHttpClient
Cloudflare WorkersYesYesNo (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.

javascript
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:

javascript
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.

javascript
// 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

javascript
const response = await fetch("https://api.example.com/users");
const users = await response.json();
console.log(users);

Output:

text
[
  { 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.

javascript
// 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)
MethodReturnsUse for
.json()Parsed JS valueREST APIs
.text()stringHTML, plaintext, CSV, debug logs
.blob()BlobImages, PDFs, any binary you'll hand to the DOM
.arrayBuffer()ArrayBufferBinary parsing, hashing, WebAssembly modules
.formData()FormDataMultipart responses (rare — but symmetric with uploads)

The body can only be consumed once. Calling .json() after .text() on the same response throws TypeError: Body has already been consumed. Clone the response first if you need to read it twice: response.clone().

javascript
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:

javascript
const res = await fetch(url, { method: "DELETE" });
const data = res.status === 204 ? null : await res.json();

POST with a JSON body

javascript
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:

text
{ id: 3, name: 'Carol', role: 'admin', createdAt: '2026-04-26T10:00:00Z' }

PUT, PATCH, DELETE

javascript
// 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.

javascript
// 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.

javascript
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.

javascript
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

javascript
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:

text
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.

javascript
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:

text
…
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:

javascript
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:

text
{ 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.

javascript
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

javascript
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

javascript
// 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

javascript
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.

javascript
// 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.

javascript
// 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 with Access-Control-Allow-Origin: <your-origin> (not *) and Access-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 an OPTIONS preflight.
  • 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, but response.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:

ConditionExample
Method other than GET/HEAD/POSTPUT, PATCH, DELETE
Content-Type outside the three "simple" typesapplication/json, anything but text/plain, application/x-www-form-urlencoded, multipart/form-data
Any custom headerAuthorization, X-API-Key, X-Request-ID
ReadableStream bodyAny streamed upload

Required response headers

text
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

javascript
const response = await fetch("https://api.other.com/me", {
  mode: "cors",  // default
  credentials: "include",
});

console.log(response.type);  // 'cors' | 'basic' | 'opaque' | 'error'
response.typeMeaning
basicSame-origin request — full access
corsCross-origin, server allowed — full access
opaquemode: 'no-cors' was set — body unreadable, status 0
opaqueredirectRedirected with redirect: 'manual'
errorNetwork failure / blocked by CORS

fetch vs node-fetch vs axios vs got vs ky

Featurenative fetchnode-fetchaxiosgotky
Available in browserYesNo (Node only)Yes (adapter)NoYes
Available in Nodev18+ (unflagged v21+)v2/v3YesYesYes
Promise-basedYesYesYesYesYes
Automatic JSON parseManual .json()Manual .json()AutomaticAutomaticAutomatic
Rejects on 4xx/5xxNo (manual check)No (manual check)Yes (throws)Yes (throws)Yes (throws)
Request/response interceptorsNoNoYesHooksHooks
Upload progressNo (streams only)NoYesYesNo
Built-in retryNo (DIY)NoNo (plugin)YesYes
Built-in timeoutAbortSignal.timeout()Similartimeout optionYesYes
Bundle size0 (built-in)~30 kB~30 kBNode 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=...).

javascript
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

javascript
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:

text
200

Setting a global dispatcher

javascript
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

javascript
// 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 fetch decompresses br, gzip, deflate, and zstd automatically.
  • Bun.file() returns a Blob that streams from disk — pass it as body: directly to upload.
  • HTTP/2 is enabled by default; pass protocol: "http1" to force HTTP/1.1.

Deno

javascript
// 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-net to make outbound calls.
  • Cleanup client.close() is required, or the runtime will warn about resource leaks at shutdown.

Common pitfalls

  1. Treating 4xx/5xx as a thrown errorfetch only rejects on network failure. Always check response.ok (or use a wrapper that throws for you, like ky).
  2. Consuming a body twice — once you call .json(), the stream is drained. clone() before the first read if you need a second pass.
  3. Setting Content-Type on a FormData upload — the browser/runtime sets the multipart boundary header automatically. If you set it manually, the boundary is missing and the server returns 400.
  4. 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.
  5. Mixing AbortSignal.timeout() with an outer AbortController — only one signal can be passed. Use AbortSignal.any([sig1, sig2]) (Node 20+, modern browsers) to combine them.
  6. Sending a stream body without duplex: "half" — Node throws RequestInit: duplex option is required when sending a body as a stream. Always set it for upload streams.
  7. Mutating headers after constructionRequest.headers is a live Headers object, but once a request has been sent it cannot be re-sent. Build a fresh Request per attempt.
  8. Catching AbortError as a network failure — abort errors share the same rejection path as network errors. Distinguish via err.name === "AbortError" before retrying.
  9. Trusting response.url for security — after redirects, response.url shows the final URL but the original request.url is gone. If you need to enforce an origin, set redirect: "manual" and inspect Location yourself.

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.

javascript
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:

text
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().

javascript
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.

javascript
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:

text
{ 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.

javascript
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:

text
{ 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.

javascript
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:

text
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.

javascript
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.

javascript
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:

text
100.0% (10485760/10485760 bytes)
Done: 10485760 bytes

Reusable fetch wrapper

javascript
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" }),
});