Skip to content
Snippets

2 min read

fetchWithTimeout

A fetch wrapper that gives up after a timeout, with a clear error and optional retries.

  • ts

fetch never gives up on its own. If a server hangs, your request hangs with it, and so does the spinner waiting on it. This wrapper adds a timeout and a clear error, and it still respects an abort signal you pass in yourself.

ts
export class TimeoutError extends Error {
  constructor(ms: number) {
    super(`Request timed out after ${ms}ms`);
    this.name = 'TimeoutError';
  }
}

export async function fetchWithTimeout(
  input: RequestInfo | URL,
  { timeout = 8000, signal, ...init }: RequestInit & { timeout?: number } = {}
) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(new TimeoutError(timeout)), timeout);

  // Also abort when the caller's own signal does.
  const forward = () => controller.abort(signal?.reason);
  if (signal?.aborted) forward();
  else signal?.addEventListener('abort', forward, { once: true });

  try {
    return await fetch(input, { ...init, signal: controller.signal });
  } finally {
    clearTimeout(timer);
    signal?.removeEventListener('abort', forward);
  }
}

What the timeout covers

The timer stops as soon as the response headers arrive, so it guards against a server that never answers, not against a slow download of a large body.

Usage

ts
try {
  const res = await fetchWithTimeout('/api/posts', { timeout: 5000 });
  const posts = await res.json();
} catch (error) {
  if (error instanceof TimeoutError) {
    // The server took too long: offer a retry button.
  }
}

With retries

For flaky networks, wrap it in a loop that retries on failures and server errors, waiting a little longer each time (300ms, then 600ms, and so on):

ts
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function fetchWithRetry(
  input: RequestInfo | URL,
  { retries = 2, ...init }: RequestInit & { timeout?: number; retries?: number } = {}
) {
  for (let attempt = 0; ; attempt++) {
    try {
      const res = await fetchWithTimeout(input, init);
      if (res.status < 500 || attempt >= retries) return res;
    } catch (error) {
      // Stop on the last attempt, or when the caller cancelled on purpose.
      if (attempt >= retries || init.signal?.aborted) throw error;
    }
    await sleep(2 ** attempt * 300);
  }
}