export class TimeoutError extends Error {
constructor(public readonly ms: number) {
super(`Request timed out after ${ms}ms`);
this.name = "TimeoutError";
}
}
function linkSignals(external: AbortSignal | undefined, controller: AbortController): () => void {
if (!external) return () => {};
if (external.aborted) {
controller.abort(external.reason);
return () => {};
}
const onAbort = () => controller.abort(external.reason);
external.addEventListener("abort", onAbort, { once: true });
return () => external.removeEventListener("abort", onAbort);
}
export async function withTimeout(
input: RequestInfo | URL,
init: RequestInit & { timeoutMs: number; signal?: AbortSignal }
): Promise<Response> {
const { timeoutMs, signal, ...rest } = init;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const unlink = linkSignals(signal, controller);
try {
return await fetch(input, { ...rest, signal: controller.signal });
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError" && !signal?.aborted) {
throw new TimeoutError(timeoutMs);
}
throw err;
} finally {
clearTimeout(timer);
unlink();
}
}
import { withTimeout, TimeoutError } from "./withTimeout";
export class HttpError extends Error {
constructor(public readonly status: number, message: string) {
super(message);
this.name = "HttpError";
}
}
interface RequestOptions {
method?: string;
body?: unknown;
timeoutMs?: number;
retries?: number;
signal?: AbortSignal;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export class ApiClient {
constructor(private readonly baseUrl: string) {}
async request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const { method = "GET", body, timeoutMs = 5000, retries = 2, signal } = opts;
return this.retryWithBackoff(retries, async () => {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
const res = await withTimeout(`${this.baseUrl}${path}`, {
method,
signal,
timeoutMs,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (res.status >= 500) throw new HttpError(res.status, `Server error ${res.status}`);
if (!res.ok) {
const text = await res.text();
throw new HttpError(res.status, text || res.statusText);
}
return (await res.json()) as T;
});
}
private async retryWithBackoff<T>(retries: number, fn: () => Promise<T>): Promise<T> {
let attempt = 0;
for (;;) {
try {
return await fn();
} catch (err) {
const retriable = err instanceof TimeoutError || (err instanceof HttpError && err.status >= 500);
if (!retriable || attempt >= retries) throw err;
await sleep(2 ** attempt * 100 + Math.random() * 50);
attempt++;
}
}
}
}
import { useEffect, useState } from "react";
import { ApiClient } from "./ApiClient";
const client = new ApiClient("/api");
interface State<T> {
data: T | null;
error: string | null;
loading: boolean;
}
export function useApiResource<T>(path: string, timeoutMs = 5000): State<T> {
const [state, setState] = useState<State<T>>({ data: null, error: null, loading: true });
useEffect(() => {
const controller = new AbortController();
setState((s) => ({ ...s, loading: true, error: null }));
client
.request<T>(path, { timeoutMs, signal: controller.signal })
.then((data) => setState({ data, error: null, loading: false }))
.catch((err: unknown) => {
if (err instanceof DOMException && err.name === "AbortError") return;
const message = err instanceof Error ? err.message : "Unknown error";
setState({ data: null, error: message, loading: false });
});
return () => controller.abort();
}, [path, timeoutMs]);
return state;
}
A common source of hung requests is that fetch has no built-in timeout: without intervention a stalled connection can leave a promise pending forever. This snippet builds a small HTTP client that layers deadline enforcement and cancellation on top of fetch using the standard AbortController, then shows how a React component wires an in-flight request to the component lifecycle.
In withTimeout helper, AbortController is the core primitive. The function creates a controller, arms a setTimeout that calls controller.abort() after ms, and passes controller.signal down to fetch. When the timer fires, fetch rejects with a DOMException whose name is AbortError; the helper catches that and rethrows a typed TimeoutError so callers can distinguish a deadline breach from a genuine failure. The finally block always calls clearTimeout, which matters because a leaked timer can hold the event loop open or fire spuriously on a reused controller.
The helper also honors an external signal. Because a single fetch accepts only one signal, linkSignals bridges an optional caller-provided AbortSignal to the internal controller, so either the timeout or an upstream cancellation aborts the request. The listener is registered with { once: true } and cleaned up to avoid accumulating handlers across retries.
In ApiClient, request composes these pieces and adds bounded retries via retryWithBackoff. Only idempotent conditions are retried — a timeout or a 5xx — while a 4xx response throws an HttpError immediately, since retrying client errors wastes budget. Each attempt gets a fresh timeout, and if the caller's signal is already aborted the client fails fast rather than starting work.
In useApiResource hook, the pattern is completed on the client side: an AbortController is created per effect run and aborted in the cleanup function. This cancels the request when the component unmounts or when dependencies change, preventing the classic "setState on an unmounted component" race and avoiding wasted bandwidth on stale requests. The hook filters out AbortError so a deliberate cancellation is not surfaced as a user-facing error. Together these files show the trade-off: AbortController gives cooperative cancellation, but the caller is responsible for wiring timers, cleanup, and error classification correctly.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
delay: { type: Number, default: 800 },
Stimulus: autosave draft with Turbo-friendly requests
import React from "react";
type FallbackProps = {
error: Error;
reset: () => void;
};
React Error Boundary + error reporting hook
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: true,
});
/** @type {import('next').NextConfig} */
Next.js bundle analyzer for targeted performance work
Share this code
Here's the card — post it anywhere.