Ch. 1 · JavaScript

JavaScript Timeouts with Promise.race

Why Promise.race times out the caller but not the work, how to clear the timer and abort the request, and when AbortSignal.timeout is enough.

~7 min readadvancedupdated Oct 19, 2026

A timeout helper built on Promise.race is a classic interview question, partly because the first version everyone writes looks correct and passes a quick test. It races your operation against a timer, and if the timer wins, the caller gets a rejection. What it doesn’t do is stop anything. The request keeps going, the server keeps working, and the timer you started keeps running even when the operation wins.

This guide builds that first version, shows its two problems with real measurements, fixes them with cleanup and an AbortController, and then shows the built-in signals that make the hand-written version unnecessary for fetch. All timings come from Node 22 against a local test server that waits 3 seconds before responding.

The naive version and what it actually does

Here’s the helper as it usually appears:

class TimeoutError extends Error {
  constructor(ms) {
    super(`Timed out after ${ms} ms`);
    this.name = 'TimeoutError';
  }
}

function naiveTimeout(promise, ms) {
  const timer = new Promise((_, reject) =>
    setTimeout(() => reject(new TimeoutError(ms)), ms));
  return Promise.race([promise, timer]);
}
JavaScript

Promise.race takes an iterable of promises and returns a promise that settles the same way as whichever input settles first, fulfilled or rejected. If the operation finishes in time, the race fulfils with its value. If the timer fires first, the race rejects with the TimeoutError.

Now use it on the slow endpoint, while also watching what happens to the original request:

const p = fetch(url).then(r => r.text()).then(t =>
  console.log('body still arrived at', Date.now() - t0, 'ms:', t));

try {
  await naiveTimeout(p, 500);
} catch (e) {
  console.log(e.name, e.message, 'at', Date.now() - t0, 'ms');
}
// TimeoutError Timed out after 500 ms at 505 ms
// body still arrived at 3017 ms: {"ok":true}
JavaScript

The caller gave up at 505 ms, exactly as intended. But the request ran to completion at 3017 ms, and its .then callback ran. Promises represent a result that’s coming; they don’t own or control the work producing it. Losing a race doesn’t cancel anything, because nothing in Promise.race knows how to cancel.

That has real consequences. In a browser, the user clicked “Save”, saw “Request timed out”, clicked again, and now two saves are in flight. On a server, a slow downstream service gets every request it was already struggling with, plus your retries. And the “late” callback might update state that the caller already treated as failed, such as writing a stale search result over a newer one.

The timer leaks when the operation wins

The second problem shows up when the operation is fast. Here’s a 50 ms operation raced against a 5-second timeout, in a script that does nothing else:

const timeout = ms => new Promise((_, reject) =>
  setTimeout(() => reject(new Error(`Timed out after ${ms} ms`)), ms));
const fast = new Promise(r => setTimeout(() => r('fast'), 50));
console.log(await Promise.race([fast, timeout(5000)]));
console.log('race settled at', Math.round(performance.now()), 'ms');
process.on('exit', () =>
  console.log('process exited at', Math.round(performance.now()), 'ms'));
// fast
// race settled at 65 ms
// process exited at 5015 ms
JavaScript

The result arrived at 65 ms, but the process stayed alive for five seconds, because a pending setTimeout keeps Node’s event loop running. In a CLI tool or a serverless function that’s wasted time (and in some environments, billed time). In a long-running server handling thousands of requests, each one leaves a timer and its closure in memory until it fires. Test runners notice too: Jest warns about open handles when a test leaves timers behind.

You might expect an unhandled rejection when that timer finally rejects. There isn’t one: Promise.race attached a handler to every input, so the late rejection is handled (and ignored). The leak is quiet, which is why people don’t notice it.

A version that cleans up and cancels

Fixing both problems takes two changes. Keep a handle on the timer so you can clear it, and give the operation an AbortSignal so the timeout can actually stop it:

async function withTimeout(fn, ms) {
  const controller = new AbortController();
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => {
      const err = new TimeoutError(ms);
      controller.abort(err);
      reject(err);
    }, ms);
  });

  try {
    return await Promise.race([fn(controller.signal), timeout]);
  } finally {
    clearTimeout(timer);
  }
}

await withTimeout(signal => fetch(url, { signal }).then(r => r.text()), 500);
// TimeoutError Timed out after 500 ms at 506 ms
JavaScript

Walk through each decision.

The helper takes a function rather than a promise. By the time someone has a promise, the work has already started and you can’t attach a signal to it. Accepting fn(signal) lets the helper create the controller first and hand the signal in.

When the timer fires, it aborts the controller and rejects. fetch listens to the signal: it closes the connection and rejects its own promise. Passing the TimeoutError as the abort reason means code that inspects signal.reason sees why the abort happened.

The finally block runs on every path (success, failure, or timeout) and clears the timer. On success, that’s what stops the five-second leak. On timeout, clearing an already-fired timer is harmless.

Using await inside the try matters. If you wrote return Promise.race(...) without await, the function would return before the race settled, and the finally would clear the timer immediately, so the timeout could never fire.

Cancellation only works if the work listens

AbortSignal is cooperative. fetch, Node’s fs/promises methods, events.once, stream.pipeline and timers from node:timers/promises accept a signal option and stop when it aborts. Your own async function stops only if it checks signal.aborted between steps or calls signal.throwIfAborted(). A CPU-bound loop that never yields can’t be interrupted by any of this; it needs to move to a worker thread, which you can terminate.

There’s also a server-side limit. Aborting a fetch closes the connection, but if the server already started processing a POST /payments, it may finish. A timeout tells you that you don’t know the outcome, not that the operation didn’t happen. For anything that changes data, combine timeouts with an idempotency key so retries are safe.

The built-in signals

For fetch and other signal-aware APIs, modern runtimes already provide a timeout signal:

await fetch(url, { signal: AbortSignal.timeout(500) });
// TimeoutError: The operation was aborted due to timeout   (at 503 ms)
JavaScript

AbortSignal.timeout(ms) aborts with a DOMException named TimeoutError, and the runtime manages the timer for you, so there’s nothing to clear. It’s supported in all current browsers and in Node 17.3 and later.

Real code usually has more than one reason to cancel: the deadline, and the user navigating away or clicking “Cancel”. AbortSignal.any combines signals and aborts when the first of them does:

const user = new AbortController();
setTimeout(() => user.abort(), 200);  // user clicks Cancel at 200 ms

await fetch(url, { signal: AbortSignal.any([user.signal, AbortSignal.timeout(500)]) });
// AbortError: This operation was aborted   (at 202 ms)
JavaScript

The error name tells you which source won: TimeoutError from the deadline, AbortError from a plain abort() call. That lets the UI show “Request timed out, try again” for one and nothing at all for the other, since the user asked for it. AbortSignal.any arrived later than timeout (Node 20.3, and browsers from 2023 to 2024), so check your targets.

Keep the Promise.race pattern for operations that don’t accept a signal, such as a third-party SDK call. In that case, be honest in naming and documentation: the helper times out the wait, not the work.

Two Promise.race details worth knowing

If several inputs are already settled when you call race, the first one in iteration order wins: Promise.race([Promise.resolve('a'), Promise.resolve('b')]) gives 'a'. And Promise.race([]) returns a promise that never settles, which can hang a caller if the array is built dynamically and happens to be empty. Promise.any is the sibling that ignores rejections and waits for the first fulfilment, which suits “try three mirrors, use the first that works”, not timeouts.

Interview question: a search box with timeouts

Question. A search box calls fetch('/api/search?q=' + term) on every keystroke, wrapped in the naive Promise.race timeout from above with 2 seconds. Users report results for an old query briefly replacing results for the current one, and the backend team says the search service gets far more requests than the frontend shows as completed. Explain both symptoms and fix the design.

Answer. Both come from the race not cancelling anything. Each keystroke starts a request that keeps running after it loses the race or after a newer keystroke makes it irrelevant, so the backend serves every one of them. When an older request finishes after a newer one, its .then still runs and writes stale results to the UI. The fix is to give each search its own AbortController, abort the previous controller when a new keystroke arrives, and combine it with a deadline: fetch(url, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(2000)]) }). In the catch block, ignore AbortError (that’s a superseded query) and show a message for TimeoutError. Debouncing keystrokes by 200 to 300 ms cuts the request count further. As a last guard, compare the term a response was for with the current term before rendering, so even a response that slips through can’t overwrite newer results.

See also

More in JavaScript

esc