FrontendPro

86Implement a fetch wrapper with timeout and AbortController

mediumAbout 35 minutesCodingFreefetchabortcontrollernetworktimers

Problem

fetch has no timeout: on a bad network a request can hang for minutes. Implement fetchWithTimeout(url, { timeout = 8000, signal, ...options } = {}):

  • It calls fetch(url, options) and resolves with the Response.
  • If the request takes longer than timeout ms, it aborts the request and rejects with an error whose name is "TimeoutError".
  • If the caller passes their own signal and aborts it, the request is aborted and the promise rejects with that signal's reason (an AbortError by default), so callers can tell "cancelled" from "timed out".
  • It cleans up: the timer is cleared and the listener on the caller's signal is removed when the request finishes, however it finishes.
const controller = new AbortController();
cancelButton.onclick = () => controller.abort();

try {
  const res = await fetchWithTimeout("/api/report", { timeout: 5000, signal: controller.signal });
  render(await res.json());
} catch (error) {
  if (error.name === "TimeoutError") showToast("The server is slow, please retry");
  else if (error.name !== "AbortError") throw error;
}

Try it yourself

Write your solution, run it, then submit to check it against the tests.

App.jsxReact + DOM

Step by step

Build the solution one idea at a time.

  1. 1

    One controller owns the request

    Create an AbortController and pass its signal to fetch. Both the timeout and the caller's signal abort this one controller, so the request has a single "stop" switch.

  2. 2

    Abort with a reason

    controller.abort(reason) makes fetch reject with reason. For the timeout, use new DOMException("Request timed out after 5000 ms", "TimeoutError"), so callers can check error.name.

  3. 3

    Forward the caller's signal

    If the caller's signal is already aborted, abort immediately. Otherwise add an abort listener that calls controller.abort(signal.reason).

  4. 4

    Clean up in finally

    clearTimeout(timer) and signal.removeEventListener("abort", onAbort) in a finally block. Without this, a long-lived caller signal (for example one per page) collects a listener for every request, and an unfinished timer can abort a request that already succeeded.

  5. 5

    Throw the reason you aborted with

    If the controller's signal is aborted, rethrow controller.signal.reason. It is the exact TimeoutError or caller reason, independent of how a particular browser words its own abort errors.

Approach

Combine two cancellation sources (a timer and an optional external signal) into one AbortController, abort with descriptive reasons, and guarantee cleanup with finally.

Solution

async function fetchWithTimeout(url, { timeout = 8000, signal, ...options } = {}) {
  const controller = new AbortController();
  const timer = setTimeout(() => {
    controller.abort(new DOMException(`Request timed out after ${timeout} ms`, "TimeoutError"));
  }, timeout);

  const onAbort = () => controller.abort(signal.reason);
  if (signal) {
    if (signal.aborted) onAbort();
    else signal.addEventListener("abort", onAbort, { once: true });
  }

  try {
    return await fetch(url, { ...options, signal: controller.signal });
  } catch (error) {
    if (controller.signal.aborted) throw controller.signal.reason;
    throw error;
  } finally {
    clearTimeout(timer);
    signal?.removeEventListener("abort", onAbort);
  }
}

Complexity

Time: O(1) overhead per request. Space: O(1).

Edge cases

  • A response arriving just before the timeout resolves normally, and the timer is cleared.
  • A caller signal that is already aborted rejects without waiting.
  • Network errors (TypeError: Failed to fetch) pass through unchanged.
  • The timeout covers receiving the headers. Reading a slow body (res.json()) after fetch resolves is not covered; see the follow-up.

Follow-up questions

Answer each one out loud first, then reveal the model answer.