../

Fetch API

fetch() with Request, Response, Headers and AbortSignal, typed for TypeScript, plus the HTTP rules (CORS, caching, latency) that decide how safe and how fast a request is. The same API ships in browsers, Node 18+, Bun and Deno.

Basics

const res = await fetch("https://api.example.com/users/1");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const user: unknown = await res.json();

fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>. The input may be a string, a URL or a Request; init overrides fields of a Request input.

Response memberTypeMeaning
okbooleanstatus is 200–299
statusnumberHTTP status; 0 for opaque and error responses
statusTextstringreason phrase; always "" over HTTP/2 and HTTP/3
headersHeadersresponse headers (filtered cross-origin)
urlstringfinal URL after redirects
redirectedbooleanat least one redirect was followed
typeResponseTypebasic, cors, opaque, opaqueredirect, error
bodyReadableStream | nullraw byte stream
bodyUsedbooleanbody already consumed
Static helperReturns
Response.json(data, init?)JSON response with Content-Type set
Response.error()network-error response (type: "error")
Response.redirect(url, status?)redirect response (301/302/303/307/308)
new Request(url, init?)reusable request; req.clone() to send twice

The static helpers are mostly for servers and tests (Bun.serve, route handlers, MSW).

Request options

Every field of RequestInit is optional.

OptionValuesDefaultNotes
method"GET", "POST", "PUT", "PATCH", "DELETE", …"GET"only standard verbs are upper-cased; write "PATCH"
headersHeaders, Record<string, string>, [string, string][]nonesee Headers
bodystring, Blob, BufferSource, FormData, URLSearchParams, ReadableStreamnonenot allowed with GET/HEAD
modecors, same-origin, no-cors, navigatecorsnavigate is for documents only
credentialsomit, same-origin, includesame-origincookies, HTTP auth, client certs
cachedefault, no-store, reload, no-cache, force-cache, only-if-cacheddefaultbrowser HTTP cache; see Caching
redirectfollow, error, manualfollowat most 20 hops; manual gives opaqueredirect
referrersame-origin URL, "", "about:client""about:client""" sends no Referer
referrerPolicyno-referrer, origin, strict-origin-when-cross-origin, …document policybrowsers default to strict-origin-when-cross-origin
integrity"sha384-<base64>"""Subresource Integrity check on the body
keepalivebooleanfalserequest may outlive the page; 64 KiB body budget
signalAbortSignalnonecancel or time out; see Errors & timeouts
priorityhigh, low, autoautoa hint relative to other fetches; check support
duplex"half"nonerequired when body is a ReadableStream

Reading bodies

MethodResolves toNotes
json()Promise<any>parses JSON; SyntaxError on bad or empty body
text()Promise<string>decodes as UTF-8, whatever the charset
blob()Promise<Blob>type taken from Content-Type
arrayBuffer()Promise<ArrayBuffer>raw bytes
bytes()Promise<Uint8Array>newer (Baseline 2025); else new Uint8Array(await res.arrayBuffer())
formData()Promise<FormData>multipart/form-data or urlencoded bodies
bodyReadableStreamread incrementally; see Streaming

The body is a stream and can be read once. A second read rejects with TypeError and bodyUsed is true. Call clone() before the first read to keep a copy; both branches buffer until read, so an unread clone of a large body holds it all in memory.

const res = await fetch("/api/report");
const backup = res.clone();
try {
  const data: unknown = await res.json();
} catch {
  throw new Error(`Not JSON: ${await backup.text()}`);
}
GotchaFix
204 No Contentdon't call json(); check res.status === 204 first
HTML error page on 502check res.headers.get("Content-Type") before json()
json() returns anyannotate as unknown and validate; see Validating responses

Sending data

body typeContent-Type set automatically
stringtext/plain;charset=UTF-8
URLSearchParamsapplication/x-www-form-urlencoded;charset=UTF-8
FormDatamultipart/form-data; boundary=…
Blob / Filethe blob's type, if not empty
ArrayBuffer, typed arraynone
ReadableStreamnone

JSON

const res = await fetch("/api/users", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Ada", role: "admin" }),
});

Without the header the server sees text/plain and many frameworks won't parse the body.

FormData

declare const file: File;
 
const form = new FormData();
form.append("title", "Q3 report");
form.append("file", file, "report.pdf");
await fetch("/api/upload", { method: "POST", body: form });

Never set Content-Type yourself here: the browser must add the boundary parameter. Build from a form element with new FormData(formEl); see Forms.

URLSearchParams

const params = new URLSearchParams({
  q: "fetch",
  page: "2",
});
 
// as a form body
await fetch("/login", { method: "POST", body: params });
 
// as a query string: URL handles the encoding
const url = new URL("/search", "https://example.com");
url.searchParams.set("q", "a&b c");
await fetch(url); // .../search?q=a%26b+c

Blob / File

declare const file: File;
 
await fetch(`/api/files/${encodeURIComponent(file.name)}`, {
  method: "PUT",
  body: file, // Content-Type from file.type
});

Streaming request body

const enc = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
  start(controller) {
    controller.enqueue(enc.encode("line 1\n"));
    controller.enqueue(enc.encode("line 2\n"));
    controller.close();
  },
});
 
// lib.dom's RequestInit doesn't declare duplex yet
const init: RequestInit & { duplex: "half" } = {
  method: "POST",
  body,
  duplex: "half",
};
await fetch("/api/ingest", init);

Headers

declare const token: string;
 
const h = new Headers({
  "Content-Type": "application/json",
});
h.append("Accept", "application/json");
h.set("Authorization", `Bearer ${token}`);
 
h.get("content-type");           // "application/json"
h.has("ACCEPT");                  // true: case-insensitive
for (const [name, value] of h) {}
// names come out lower-cased
MethodEffect
get(name)value or null; repeated headers joined with ", "
getSetCookie()string[] of every Set-Cookie (server runtimes only)
set(name, value)replaces all values
append(name, v)adds another value
has / deletetest / remove
entries(), keys(), forEachiterate, sorted and lower-cased

Forbidden request headers are dropped silently by browsers, because the user agent owns them: Host, Cookie, Content-Length, Origin, Referer, Connection, Keep-Alive, Accept-Encoding, Accept-Charset, Transfer-Encoding, TE, Trailer, Upgrade, Via, Date, DNT, Expect, and anything starting with Sec- or Proxy-. Server runtimes let you set most of them. In the other direction, browsers never expose Set-Cookie.

HeaderSent byExamplePurpose
Acceptclientapplication/jsonpreferred response type
Content-Typebothapplication/json; charset=utf-8type of the body
AuthorizationclientBearer eyJ…credentials
Accept-Languageclienten-GB,en;q=0.8locale negotiation
If-None-Matchclient"v42"conditional GET with an ETag
Idempotency-Keyclienta UUIDsafe retries of POST (convention)
Cache-Controlbothmax-age=60caching rules
ETagserver"v42"version of the resource
Locationserver/users/7redirect target, created resource
Retry-Afterserver120wait before retrying (429, 503)
Content-Encodingserverbrcompression applied

Errors & timeouts

What went wrongHow it surfacesCheck
offline, DNS, refused, CORS, bad URLpromise rejectserr instanceof TypeError
controller.abort()rejectserr.name === "AbortError"
AbortSignal.timeout(ms) firedrejectserr.name === "TimeoutError"
HTTP 4xx / 5xxresolves, ok: falseres.ok, res.status
body isn't JSONjson() rejectserr instanceof SyntaxError
JSON has the wrong shapenothing, unless you validateZod parse throws ZodError

A browser never tells you why a network error happened (CORS vs offline look the same); the details are only in DevTools.

http-error.ts
export class HttpError extends Error {
  override name = "HttpError";
  readonly status: number;
  readonly body: string;
 
  constructor(res: Response, body: string) {
    super(`HTTP ${res.status} ${res.statusText} ${res.url}`);
    this.status = res.status;
    this.body = body;
  }
}
 
export async function ensureOk(res: Response) {
  if (!res.ok) throw new HttpError(res, await res.text());
  return res;
}

Timeouts and cancellation

declare const url: string;
 
try {
  const res = await fetch(url, {
    signal: AbortSignal.timeout(5_000),
  });
  await ensureOk(res);
} catch (err) {
  if (!(err instanceof Error)) throw err;
  if (err.name === "TimeoutError") {
    // took longer than 5 s
  } else if (err.name === "AbortError") {
    // canceled by the user or by code
  } else if (err instanceof TypeError) {
    // network failure
  } else throw err;
}

Combine a caller's signal with a timeout; whichever fires first wins and its reason is used:

function withTimeout(ms: number, signal?: AbortSignal) {
  const timeout = AbortSignal.timeout(ms);
  return signal
    ? AbortSignal.any([signal, timeout])
    : timeout;
}
 
const controller = new AbortController();
const res = await fetch("/api/search?q=ts", {
  signal: withTimeout(8_000, controller.signal),
});
controller.abort(); // e.g. on unmount or a newer keystroke
APISupportNotes
AbortControllereverywhereabort(reason?); default AbortError
AbortSignal.timeout(ms)Baseline widely availablerejects with TimeoutError
AbortSignal.any(signals)Baseline 2024, Node 20.3+aborts when any input aborts

The signal stays attached after headers arrive: aborting mid-body makes json() or a stream read reject too, so one timeout covers the whole exchange.

Validating responses

res.json() returns any, so await res.json() as User compiles whatever the server sends. The cast is a promise nobody checks; a renamed field turns into undefined far away from the fetch. Parse at the boundary instead.

import { z } from "zod";
 
const User = z.object({
  id: z.number().int(),
  name: z.string(),
  email: z.email(),
  createdAt: z.iso.datetime().transform((s) => new Date(s)),
});
type User = z.output<typeof User>; // createdAt: Date
fetch-json.ts
import { z } from "zod";
 
export async function fetchJson<S extends z.ZodType>(
  input: string | URL,
  schema: S,
  init?: RequestInit,
): Promise<z.output<S>> {
  const res = await fetch(input, init);
  if (!res.ok) {
    throw new HttpError(res, await res.text());
  }
  return schema.parse(await res.json());
}
 
const users = await fetchJson("/api/users", z.array(User));
//    ^? { id: number; name: string; ... }[]

Use schema.safeParse(json) when you want a { success, data | error } value instead of a throw. The same idea without Zod: a type guard (x: unknown): x is User; see Fundamentals.

Streaming

response.body is a ReadableStream<Uint8Array>; read it as chunks arrive instead of buffering.

const res = await fetch("/logs/today.txt");
if (!res.body) throw new Error("No body");
 
const reader = res.body
  .pipeThrough(new TextDecoderStream())
  .getReader();
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  console.log(value); // a string chunk, not a line
}

for await (const chunk of res.body) is shorter and works in Node, Bun, Deno, Chrome and Firefox; Safari was late to support it, so use getReader() in browser code that must run everywhere.

Download progress

async function download(
  url: string,
  onProgress: (fraction: number) => void,
): Promise<Blob> {
  const res = await fetch(url);
  if (!res.ok || !res.body) {
    throw new Error(`HTTP ${res.status}`);
  }
  const total =
    Number(res.headers.get("Content-Length")) || 0;
  const reader = res.body.getReader();
  const chunks: Uint8Array<ArrayBuffer>[] = [];
  let loaded = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    chunks.push(value);
    loaded += value.byteLength;
    if (total) onProgress(Math.min(loaded / total, 1));
  }
  const type = res.headers.get("Content-Type") ?? "";
  return new Blob(chunks, { type });
}

Content-Length may be missing (chunked encoding), and with Content-Encoding: gzip it counts compressed bytes while chunks are decompressed, hence the clamp. Fetch has no upload progress event; use XMLHttpRequest.upload.onprogress for that.

For line-delimited JSON (NDJSON), server-sent events and backpressure, see Streaming.

CORS & credentials

A cross-origin request is simple (no preflight) when all of these hold:

  • method is GET, HEAD or POST
  • only CORS-safelisted headers: Accept, Accept-Language, Content-Language, Content-Type, Range
  • Content-Type is text/plain, multipart/form-data or application/x-www-form-urlencoded
  • the body is not a ReadableStream

Anything else (a JSON Content-Type, an Authorization header, PUT) first sends a preflight OPTIONS request with Access-Control-Request-Method and Access-Control-Request-Headers.

Server sendsMeaning
Access-Control-Allow-Originhttps://app.example.com or * (not * with credentials)
Access-Control-Allow-Credentialstrue to let the page read a credentialed response
Access-Control-Allow-Methodspreflight: allowed methods
Access-Control-Allow-Headerspreflight: allowed request headers
Access-Control-Max-Agepreflight cache time in seconds (browsers cap it)
Access-Control-Expose-Headersresponse headers JS may read beyond the safelist
Vary: Originwhen the allowed origin is echoed per request
modeBehavior
corsdefault; cross-origin allowed if the server opts in
same-origincross-origin requests reject with TypeError
no-corssimple methods and headers only; returns an opaque response

An opaque response has type: "opaque", status: 0, empty headers and an unreadable body. It is only useful for handing to a cache or an <img>; it is never the fix for a CORS error.

credentialsCookies and HTTP auth sentServer must also send
omitnevernothing
same-originsame-origin requests only (default)nothing
includealways, cross-origin tooexact Allow-Origin + Allow-Credentials: true

Cookies still obey SameSite and the browser's third-party cookie blocking, so include alone may not send them. See Authentication.

Caching

HeaderDirectionMeaning
Cache-Control: max-age=Nresponsefresh for N seconds
Cache-Control: s-maxage=Nresponselike max-age, for shared caches (CDNs) only
Cache-Control: no-cachebothmay store, but revalidate before every use
Cache-Control: no-storebothnever store anywhere
Cache-Control: private / publicresponsebrowser only / shared caches allowed
Cache-Control: must-revalidateresponseonce stale, don't serve without revalidating
Cache-Control: immutableresponsewon't change while fresh; for hashed asset URLs
Cache-Control: stale-while-revalidate=Nresponseserve stale for N s while refreshing in the background
ETag → If-None-Matchbothversion tag; server answers 304 Not Modified if equal
Last-Modified → If-Modified-Sincebothdate-based revalidation, 1 s precision
Vary: Accept-Encoding, Originresponsecache key includes these request headers
Ageresponseseconds the response spent in a shared cache
cache optionReads cacheWrites cacheUse for
defaultfresh hit, else conditionalyesnormal requests
no-storenonosecrets, one-off data
reloadnoyesforce a refresh
no-cachealways conditional if cachedyes"check before use"
force-cacheany match, even staleyesoffline-ish reads
only-if-cachedany match, else 504-style errornorequires mode: "same-origin"

Browsers revalidate for you. Server runtimes have no HTTP cache in fetch by default, so conditional requests are manual there:

type Entry = { etag: string; data: unknown };
const cache = new Map<string, Entry>();
 
async function getCached(url: string): Promise<unknown> {
  const hit = cache.get(url);
  const headers: HeadersInit = hit
    ? { "If-None-Match": hit.etag }
    : {};
  const res = await fetch(url, { headers });
  if (res.status === 304 && hit) return hit.data;
  const data: unknown = await res.json();
  const etag = res.headers.get("ETag");
  if (etag) cache.set(url, { etag, data });
  return data;
}

HTTP performance

Most API calls and page resources are small, so their time is dominated by latency (round trips), not bandwidth. Doubling bandwidth barely changes load time; cutting round trips (RTT) helps linearly.

Cost of a new HTTPS connectionRound tripsNotes
DNS lookup0–1+cached by OS and browser
TCP handshake1SYN, SYN-ACK, ACK
TLS 1.2 handshake2on top of TCP
TLS 1.3 handshake10-RTT on resumption (replayable data)
QUIC (HTTP/3)1transport and TLS 1.3 combined; 0-RTT resume
the request itself1plus server time

TCP slow start: a new connection may send only about 10 segments (~14 KB) before the first ACK, and the window roughly doubles each RTT. A fresh connection can't use the full bandwidth, which is why reused, warm connections are much faster and why the first ~14 KB of a response matter.

VersionTransportRequests per connectionHead-of-line blockingHeaders
HTTP/1.1TCPone at a time; ~6 conns per hostper connection, at HTTP and TCPplain text
HTTP/2TCP + TLSmany multiplexed streamsa lost TCP packet stalls every streamHPACK
HTTP/3QUIC on UDPmany independent streamsonly the affected stream waitsQPACK

HTTP/3 also survives network changes (connection migration, e.g. Wi-Fi to mobile). HTTP/2 server push is gone from browsers; use 103 Early Hints or preload instead.

TechniqueWhy it helps
reuse connectionsskip handshakes and slow start; keep-alive is on by default
parallel, not waterfallPromise.all turns N round trips into ~1
fewer requestsbatch endpoints, bundle, inline tiny critical data
compressbr / gzip (zstd in newer browsers) for text
CDN / edgeshorter physical distance, lower RTT
cachethe fastest request is the one not sent
preconnecthandshake early for an origin you'll hit soon
// ❌ waterfall: two sequential round trips
const a = await fetch("/api/profile");
const b = await fetch("/api/settings");
 
// ✅ in parallel
const [profile, settings] = await Promise.all([
  fetch("/api/profile"),
  fetch("/api/settings"),
]);
<link rel="preconnect" href="https://api.example.com"
      crossorigin>
<link rel="dns-prefetch" href="https://cdn.example.com">

crossorigin matters: CORS fetches use a separate connection pool from no-cors loads. More on Promise.all in Async & promises.

A typed API client

A small wrapper: base URL, JSON in and out, a timeout, retries for idempotent requests, and errors returned as a discriminated union instead of thrown.

api.ts
import { z } from "zod";
 
export type ApiError =
  | { kind: "network"; cause: unknown }
  | { kind: "timeout" }
  | { kind: "http"; status: number; body: string }
  | { kind: "parse"; error: z.ZodError };
 
export type Result<T> =
  | { ok: true; data: T }
  | { ok: false; error: ApiError };
 
type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
 
type ClientOptions = {
  baseUrl: string;
  headers?: HeadersInit;
  timeoutMs?: number;
  retries?: number;
};
 
const RETRY_STATUS = new Set([408, 429, 500, 502, 503, 504]);
const IDEMPOTENT = new Set<Method>(["GET", "PUT", "DELETE"]);
const sleep = (ms: number) =>
  new Promise<void>((r) => setTimeout(r, ms));
const fail = (error: ApiError) =>
  ({ ok: false, error }) as const;
 
export function createClient(opts: ClientOptions) {
  const { timeoutMs = 10_000, retries = 2 } = opts;
 
  async function attempt<S extends z.ZodType>(
    method: Method,
    url: URL,
    schema: S,
    body: unknown,
  ): Promise<Result<z.output<S>>> {
    const headers = new Headers(opts.headers);
    headers.set("Accept", "application/json");
    if (body !== undefined) {
      headers.set("Content-Type", "application/json");
    }
    let res: Response;
    try {
      res = await fetch(url, {
        method,
        headers,
        body:
          body === undefined ? null : JSON.stringify(body),
        signal: AbortSignal.timeout(timeoutMs),
      });
    } catch (cause) {
      const timedOut =
        cause instanceof Error &&
        cause.name === "TimeoutError";
      const error: ApiError = timedOut
        ? { kind: "timeout" }
        : { kind: "network", cause };
      return fail(error);
    }
    if (!res.ok) {
      const { status } = res;
      const text = await res.text();
      return fail({ kind: "http", status, body: text });
    }
    const json: unknown =
      res.status === 204 ? null : await res.json();
    const parsed = schema.safeParse(json);
    return parsed.success
      ? { ok: true, data: parsed.data }
      : fail({ kind: "parse", error: parsed.error });
  }
 
  async function request<S extends z.ZodType>(
    method: Method,
    path: string,
    schema: S,
    body?: unknown,
  ): Promise<Result<z.output<S>>> {
    const url = new URL(path, opts.baseUrl);
    const tries = IDEMPOTENT.has(method) ? retries + 1 : 1;
    let result = await attempt(method, url, schema, body);
    for (let i = 1; i < tries && shouldRetry(result); i++) {
      // exponential backoff with jitter
      await sleep(2 ** i * 200 + Math.random() * 100);
      result = await attempt(method, url, schema, body);
    }
    return result;
  }
 
  return {
    get: <S extends z.ZodType>(path: string, schema: S) =>
      request("GET", path, schema),
    post: <S extends z.ZodType>(
      p: string,
      s: S,
      b: unknown,
    ) =>
      request("POST", p, s, b),
    request,
  };
}
 
function shouldRetry(r: Result<unknown>): boolean {
  if (r.ok) return false;
  const e = r.error;
  return (
    e.kind === "network" ||
    e.kind === "timeout" ||
    (e.kind === "http" && RETRY_STATUS.has(e.status))
  );
}
usage.ts
const api = createClient({
  baseUrl: "https://api.example.com",
});
 
const r = await api.get("/users/1", User);
if (r.ok) {
  console.log(r.data.name); // typed from the schema
} else {
  switch (r.error.kind) {
    case "http":    console.error(r.error.status); break;
    case "parse":
      console.error(r.error.error.issues);
      break;
    case "timeout":
    case "network": console.error("try again later"); break;
  }
}

Extensions worth adding: honor Retry-After on 429/503, pass an Idempotency-Key to make POST retryable, accept a caller signal via AbortSignal.any, and inject fetch itself so tests can swap it; see Testing.

Recipes

JSON with Zod is in Validating responses, download progress in Download progress, and a full wrapper in A typed API client. For many requests at once, use the pool in Async & Promises.

fetch() boilerplate

A starting point for JSON calls: a timeout, a status check (fetch only rejects on network errors) and a typed result.

const TIMEOUT_MS = 10_000;
 
async function getJson<T>(url: string): Promise<T> {
  const res = await fetch(url, {
    headers: { Accept: "application/json" },
    signal: AbortSignal.timeout(TIMEOUT_MS),
  });
  if (!res.ok) throw new Error(`GET ${url}: ${res.status}`);
  return (await res.json()) as T;
}
 
async function postJson<T>(
  url: string,
  body: unknown,
): Promise<T> {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(TIMEOUT_MS),
  });
  if (!res.ok) throw new Error(`POST ${url}: ${res.status}`);
  return (await res.json()) as T;
}
 
type User = { id: string; name: string };
const user = await getJson<User>("/api/users/1");
const made = await postJson<User>("/api/users", {
  name: "Ada",
});

as T trusts the server. Validate with a schema when the data matters (Validating responses).

Download a response as a file

When the file needs an auth header, so a plain <a href download> link won't work.

async function downloadFile(
  url: string,
  init?: RequestInit, // e.g. an Authorization header
): Promise<void> {
  const res = await fetch(url, init);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const cd = res.headers.get("Content-Disposition") ?? "";
  const name =
    /filename="?([^";]+)"?/i.exec(cd)?.[1] ?? "download";
  const href = URL.createObjectURL(await res.blob());
  const a = Object.assign(document.createElement("a"), {
    href,
    download: name,
  });
  a.click();
  setTimeout(() => URL.revokeObjectURL(href), 0);
}
 
await downloadFile("/api/reports/42.csv", {
  headers: { Authorization: "Bearer <token>" },
});

Files generated in the browser are in File I/O.

Upload with progress

When users need a progress bar for an upload: fetch has no upload progress event, so fall back to XMLHttpRequest.

function upload(
  url: string,
  body: FormData | Blob,
  onProgress: (done: number) => void,
  signal?: AbortSignal,
): Promise<string> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("POST", url);
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) onProgress(e.loaded / e.total);
    };
    xhr.onload = () =>
      xhr.status < 300
        ? resolve(xhr.responseText)
        : reject(new Error(`HTTP ${xhr.status}`));
    xhr.onerror = () => reject(new TypeError("Network"));
    xhr.onabort = () => reject(signal?.reason);
    signal?.addEventListener("abort", () => xhr.abort());
    xhr.send(body);
  });
}

Honor Retry-After on 429 and 503

When an API rate-limits you and says how long to wait.

function retryAfterMs(res: Response): number | undefined {
  const h = res.headers.get("Retry-After");
  if (!h) return undefined;
  const secs = Number(h); // "120" or an HTTP date
  if (Number.isFinite(secs)) return secs * 1_000;
  const at = Date.parse(h);
  return Number.isNaN(at) ? undefined : at - Date.now();
}
 
async function fetchPolitely(
  input: string | URL,
  init?: RequestInit,
  maxTries = 4,
): Promise<Response> {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(input, init);
    const busy = res.status === 429 || res.status === 503;
    if (!busy || attempt >= maxTries) return res;
    const wait = retryAfterMs(res) ?? 500 * 2 ** attempt;
    await res.body?.cancel(); // release the connection
    const ms = Math.min(Math.max(wait, 0), 60_000);
    await new Promise((r) => setTimeout(r, ms));
  }
}

Cross-origin, the browser only exposes the header if the server lists Retry-After in Access-Control-Expose-Headers.

Query string from an object

When building URLs from optional filters: skip empty values, repeat array keys, and let URL do the encoding.

type QueryValue =
  | string | number | boolean | null | undefined
  | readonly (string | number)[];
 
function withQuery(
  base: string | URL,
  query: Record<string, QueryValue>,
): URL {
  const url = new URL(base);
  for (const [key, value] of Object.entries(query)) {
    if (value == null) continue; // drop null/undefined
    const items = Array.isArray(value) ? value : [value];
    for (const item of items) {
      url.searchParams.append(key, String(item));
    }
  }
  return url;
}
 
withQuery("https://api.example.com/items", {
  q: "a&b",
  tag: ["ts", "bun"],
  page: 2,
  cursor: undefined,
}).href; // ...items?q=a%26b&tag=ts&tag=bun&page=2

Refresh the token on 401

When access tokens are short-lived: retry once after a refresh, and let concurrent 401s share one refresh call.

declare function getToken(): string | undefined;
declare function refreshToken(): Promise<string>;
 
let refreshing: Promise<string> | undefined;
 
export async function authFetch(
  input: string | URL,
  init: RequestInit = {}, // body must be re-sendable
): Promise<Response> {
  const send = (tok: string | undefined) => {
    const headers = new Headers(init.headers);
    if (tok) headers.set("Authorization", `Bearer ${tok}`);
    return fetch(input, { ...init, headers });
  };
  const res = await send(getToken());
  if (res.status !== 401) return res;
  // many parallel 401s share a single refresh call
  refreshing ??= refreshToken().finally(() => {
    refreshing = undefined;
  });
  return send(await refreshing); // retry once
}

Analytics on page hide

When sending a final event as the user leaves; keepalive lets the request finish after the page is gone.

function track(
  event: string,
  data: Record<string, unknown> = {},
): void {
  const body = JSON.stringify({ event, ...data });
  void fetch("/api/events", {
    method: "POST",
    keepalive: true, // may outlive the page (64 KiB budget)
    headers: { "Content-Type": "application/json" },
    body,
  }).catch(() => {}); // analytics must never throw
}
 
// the last reliable moment on mobile is "hidden"
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") {
    track("page_hidden", { path: location.pathname });
  }
});

A JSON Content-Type makes a cross-origin request preflighted; send text/plain or use navigator.sendBeacon() for third-party endpoints.

References