All questions

Abortable Fetch with Timeout

Premium

Abortable Fetch with Timeout

An abortable fetch with a timeout is a network request that cancels itself if it runs past a deadline, and also cancels the moment the caller asks it to — reporting a distinct error for each case. It wraps fetch with an AbortController so that a slow server, or a user who navigates away, never leaves a request hanging forever.

Implement abortableFetchTimeout(url, { timeoutMs, signal }). It returns a Promise. Internally it creates its own AbortController and calls fetch(url, { signal }) with that controller's signal. A setTimeout of timeoutMs aborts the controller and rejects with an Error whose name is "TimeoutError". If the caller passes their own signal and it aborts, you abort the controller too and reject with an Error named "AbortError". Whichever outcome happens first wins, and the losers are ignored. On every outcome you clear the timer and remove the external listener so nothing lingers.

Signature

function abortableFetchTimeout(
  url: string,
  options: { timeoutMs: number; signal?: AbortSignal }
): Promise<Response>;

Examples

// Fetch resolves before the deadline, so you get the Response.
const res = await abortableFetchTimeout('/api/user', { timeoutMs: 5000 });
const user = await res.json();
// The server is too slow, so the timer aborts the request.
try {
  await abortableFetchTimeout('/api/slow', { timeoutMs: 3000 });
} catch (err) {
  err.name; // "TimeoutError"
}

// The caller aborts (e.g. the user left the page), so the error is "AbortError".
const controller = new AbortController();
const p = abortableFetchTimeout('/api/user', {
  timeoutMs: 5000,
  signal: controller.signal,
});
controller.abort();
await p.catch((err) => err.name); // "AbortError"

Notes

  • Two cancel sources, one request — the internal timer and the caller's signal both funnel into a single AbortController; fetch only listens to that one controller's signal.
  • Distinct error names — a timeout rejects with name === "TimeoutError"; an external abort rejects with name === "AbortError". Do not let fetch's own abort rejection surface instead.
  • First outcome wins — once the request settles (resolve, timeout, or abort), later events are no-ops; you never resolve and reject the same Promise.
  • Always clean up — clear the setTimeout and remove the external abort listener on every outcome, so no timer fires late and no listener leaks.
  • Already-aborted signal — if the caller's signal is aborted before you start, reject with "AbortError" right away and do not call fetch.

FAQ

What is the difference between the timeout and the external signal?
The timeout is a deadline you create inside the function: when it elapses you abort your own controller and reject with a TimeoutError. The external signal belongs to the caller: when they abort it (for example, the user navigated away) you abort the same controller and reject with an AbortError. Both cancel the one in-flight fetch; they differ only in who triggered the cancel and which error name you report.
Why create an internal AbortController instead of passing the external signal straight to fetch?
A fetch call listens to exactly one signal, but you need to cancel it from two independent sources: your timer and the caller's signal. The internal controller is the single signal fetch watches, and both the timer and the external signal funnel into it by calling controller.abort().
Is there a built-in that does this?
Yes. Modern browsers offer AbortSignal.timeout(ms) for the deadline and AbortSignal.any([...]) to combine several signals, so you can write fetch(url, { signal: AbortSignal.any([AbortSignal.timeout(5000), userSignal]) }). Building it by hand makes the timer, the abort, and the cleanup explicit; reach for the built-ins in production where they are supported.

Unlock the solution & editor

  • Runnable editor + tests

    Solve in the browser with instant Jest feedback.

  • Detailed solutions

    Walkthroughs, edge cases, and complexity notes.

  • Multi-framework variants

    React, Vue, Vanilla, Angular — same question, different stacks.

Upgrade to Premium