How would you implement fetch with a timeout and abort capability?
Wrap fetch with AbortController: schedule abort after N ms, optionally forward an external signal for user-initiated cancel, clearTimeout in finally. On modern runtimes use AbortSignal.timeout and AbortSignal.any to compose. Surface a meaningful TimeoutError vs AbortError so callers can react differently (retry on timeout, don't retry on user cancel).
Concrete reusable implementation, plus the modern one-liner.
Production-ready helper
type Opts = RequestInit & { timeoutMs?: number };
export async function fetchWithTimeout(
input: RequestInfo | URL,
{ timeoutMs = 8000, signal: externalSignal, ...rest }: Opts = {}
): Promise<Response> {
const controller = new AbortController();
// forward external abort (e.g., user clicked cancel)
if (externalSignal) {
if (externalSignal.aborted) controller.abort(externalSignal.reason);
else externalSignal.addEventListener('abort', () =>
controller.abort(externalSignal.reason)
);
}
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
controller.abort(new DOMException('Timeout', 'TimeoutError'));
}, timeoutMs);
try {
return await fetch(input, { ...rest, signal: controller.signal });
} catch (err) {
if (timedOut) {
throw new DOMException(`Request timed out after ${timeoutMs}ms`, 'TimeoutError');
}
throw err;
} finally {
clearTimeout(timer);
}
}Usage:
try {
const res = await fetchWithTimeout('/api/data', { timeoutMs: 5000 });
const data = await res.json();
} catch (err) {
if (err.name === 'TimeoutError') return showRetry();
if (err.name === 'AbortError') return; // user cancelled — silent
throw err;
}Modern one-liner (Chrome 116+, Node 20+)
const signal = AbortSignal.any([
userController.signal, // external cancel
AbortSignal.timeout(8000), // timeout
]);
const res = await fetch('/api/data', { signal });AbortSignal.timeout rejects with TimeoutError. AbortSignal.any aborts when any input signal aborts, with the first one's reason — so you can still distinguish timeout vs user cancel by err.name.
Retry on timeout (idempotent only)
async function fetchWithRetry(url: string, opts: Opts = {}, retries = 2): Promise<Response> {
let attempt = 0;
while (true) {
try {
return await fetchWithTimeout(url, opts);
} catch (err: any) {
attempt++;
const retriable = err.name === 'TimeoutError' || err.name === 'TypeError'; // network
if (!retriable || attempt > retries) throw err;
await new Promise(r => setTimeout(r, 250 * 2 ** (attempt - 1) + Math.random() * 100));
}
}
}Retry only safe methods (GET, HEAD, PUT-with-idempotency-key). Never auto-retry POST without an idempotency mechanism — you'll double-charge cards.
Per-flow budget, not per-request
For multi-step flows (getUser → getOrders → getShipping), set the deadline at the top:
const deadline = AbortSignal.timeout(15_000);
const user = await fetchWithTimeout(`/users/${id}`, { signal: deadline });
const orders = await fetchWithTimeout(`/orders?u=${id}`, { signal: deadline });
const shipping = await fetchWithTimeout(`/shipping?u=${id}`, { signal: deadline });Whichever request is in-flight when 15s elapses gets aborted — total flow can't exceed budget regardless of how slow any one step is.
Gotchas
- clearTimeout in finally — required to avoid keeping the event loop alive in Node.
- Body read can hang separately —
res.json()is its own operation; for streaming, set a separate read timeout. - External signal cleanup — if the external signal outlives the request, you've leaked an event listener. Use
{ once: true }or a controller you discard. - abort(reason) — pass an Error so the rejection carries context, not just
undefined. - Server keeps running — abort cancels the client read, not the server work. Make mutations idempotent.
Test it
import { test, expect } from 'vitest';
test('rejects with TimeoutError after timeoutMs', async () => {
const slow = new Promise(() => {}); // never resolves
// mock fetch to hang
global.fetch = () => slow as any;
await expect(fetchWithTimeout('/x', { timeoutMs: 50 }))
.rejects.toMatchObject({ name: 'TimeoutError' });
});Follow-up questions
- •How would you add exponential backoff with jitter to retries?
- •When is it unsafe to retry — and how do idempotency keys fix that?
- •How do you measure timeout-related tail latency in production?
- •What's the right default timeout for an interactive API call?
Common mistakes
- •No clearTimeout — keeps the event loop alive, eventually fires abort on completed requests.
- •Forwarding external signal but never removing the listener — memory leak.
- •Retrying non-idempotent POSTs on timeout — duplicate charges, duplicate sends.
- •Treating AbortError and TimeoutError the same — losing the distinction means you can't 'retry on timeout, don't retry on user-cancel.'
- •Setting timeout shorter than typical p99 latency — false positives, retry storms.
- •Using Promise.race without actually aborting — fetch keeps running in background, wasting resources.
Performance considerations
- •Timeouts are the cheapest tail-latency control you have. Without them p99 latency is bounded by the slowest origin, not your SLA. Pair with circuit breakers (open the circuit after N consecutive timeouts to a host) for system-level resilience. AbortSignal.timeout uses a single shared scheduler internally — micro-optimization but free.
Edge cases
- •AbortSignal.any not yet available on older browsers — feature-detect and fall back.
- •Aborting before fetch starts (signal.aborted is already true) — fetch rejects immediately.
- •Cross-realm signals (iframe/web worker) — the abort propagates across realms via structured cloning.
- •Streaming response: signal aborts the underlying ReadableStream too; in-progress reads reject.
- •Service worker: SW can intercept and serve from cache, sidestepping network timeout — design the SW caching policy with this in mind.
Real-world examples
- •React Query / SWR set sensible default timeouts and surface them via onError.
- •Browsers fetch their own DNS / TLS with internal timeouts — explicit fetch timeout is on top of that.
- •AWS SDK defaults to 5s connection timeout, 0 socket timeout — tune for your environment.