All code helpers
FrontendTypeScript

fetch with timeout, cancellation and typed errors

fetch has no default timeout and doesn't throw on 4xx/5xx. This small wrapper adds both, and still lets callers cancel with their own AbortSignal.

#fetch#abortcontroller#timeout#http
lib/fetch-json.ts
export class HttpError extends Error {
  readonly status: number;
  readonly body: string;

  constructor(status: number, body: string, url: string) {
    super(`HTTP ${status} for ${url}`);
    this.name = "HttpError";
    this.status = status;
    this.body = body;
  }
}

type FetchJsonOptions = Omit<RequestInit, "body"> & {
  timeoutMs?: number;
  /** Serialised as JSON with the matching Content-Type */
  json?: unknown;
};

export async function fetchJson<T>(
  url: string | URL,
  { timeoutMs = 10_000, signal, json, headers, ...init }: FetchJsonOptions = {},
): Promise<T> {
  const timeout = AbortSignal.timeout(timeoutMs);

  const res = await fetch(url, {
    ...init,
    headers: json === undefined ? headers : { "Content-Type": "application/json", ...headers },
    body: json === undefined ? undefined : JSON.stringify(json),
    // Abort on whichever comes first: the caller's signal or the timeout
    signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
  });

  if (!res.ok) {
    throw new HttpError(res.status, await res.text().catch(() => ""), String(url));
  }
  if (res.status === 204) return undefined as T;
  return (await res.json()) as T;
}

export const isTimeout = (err: unknown) =>
  err instanceof DOMException && err.name === "TimeoutError";

export const isAbort = (err: unknown) =>
  err instanceof DOMException && err.name === "AbortError";

Usage

TypeScript
import { fetchJson, HttpError, isAbort, isTimeout } from "@/lib/fetch-json";

type User = { id: string; name: string };

export async function renameUser(id: string, name: string, signal?: AbortSignal) {
  try {
    await fetchJson(`/api/users/${id}`, { method: "PATCH", json: { name }, signal });
    return await fetchJson<User>(`/api/users/${id}`, { timeoutMs: 5_000, signal });
  } catch (err) {
    if (isAbort(err)) return null; // the caller cancelled, nothing to report
    if (isTimeout(err)) console.warn("Server took too long");
    else if (err instanceof HttpError && err.status === 404) console.warn("User not found");
    throw err;
  }
}

How it works & gotchas

  • 01AbortSignal.timeout and AbortSignal.any are available in Node 20.3+ and all current browsers. No manual setTimeout/clearTimeout bookkeeping.
  • 02A timeout rejects with a TimeoutError and a manual cancel with an AbortError, so callers can tell them apart and only report real failures.
  • 03The cast to T is not validation. For data from third parties, parse the result with a schema (for example zod) before trusting it.
  • 04The timeout covers the whole request, including reading the body. For large downloads, raise it or stream the body instead.

Need this wired into a real system?

We build and harden production software end to end.

Talk to an engineer