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.
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
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
- 01
AbortSignal.timeoutandAbortSignal.anyare available in Node 20.3+ and all current browsers. No manualsetTimeout/clearTimeoutbookkeeping. - 02A timeout rejects with a
TimeoutErrorand a manual cancel with anAbortError, so callers can tell them apart and only report real failures. - 03The cast to
Tis 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.
More code helpers
useDebounce hooks for React
Wait until the user stops typing before firing a search or autosave. Includes a value version for derived state and a callback version for event handlers.
SSR-safe useLocalStorage hook
Persist small bits of UI state in localStorage without hydration mismatches in Next.js. Built on useSyncExternalStore, so it also stays in sync across components and browser tabs.
Stream LLM responses from a Next.js route handler
Send tokens to the browser as the model produces them instead of making users wait for the full completion. Uses a plain ReadableStream, so it works with any client that can read a fetch body.