Back to Networking
Networking
easy
mid

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).

7 min read·~15 min to think through

Concrete reusable implementation, plus the modern one-liner.

Production-ready helper

ts
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:

ts
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+)

ts
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)

ts
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:

ts
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 separatelyres.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

ts
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.

Senior engineer discussion

Seniors talk about deadlines, not timeouts. Every external call has a budget; budgets compose; retries cost budget. They know AbortSignal.any/timeout, but more importantly they think about *idempotency*, *retry policy*, and *circuit breaking* as a system. The wrapper is just plumbing.

Related questions