ESC

Type to search the knowledge base.

Timeout a Promise

Race work against a timer without leaking the timer or pretending the work cancelled. withTimeout, clearTimeout, and why race is not AbortController.

intermediate5 min read
  • javascript
  • promises
  • async
  • timeout

Promise.race([work, sleepThenReject(ms)]) looks like a timeout until the work wins and the timer still fires. race already attached handlers to both sides, so a late timer reject is not an unhandledrejection. The timer callback still runs, still allocates an error, and still keeps the isolate scheduling that macrotask. The helper is not done until clearTimeout runs when either side settles.

The other lie: after the timer wins, people talk as if fetch stopped. It did not. race ignores the loser. The request keeps going unless you abort. Timeout and cancellation are different tools.

Key takeaways

  • withTimeout races work against a timer and clearTimeout in finally, whichever side wins.
  • clearTimeout is for timer lifetime, not for unhandledrejection. race already handled both inputs. Clearing stops a useless macrotask and a thrown TimeoutError nobody will read.
  • Promise.race does not cancel the loser. Pair a timeout with AbortController (or AbortSignal.timeout) if the work is fetch.
  • Wrap work with Promise.resolve so thenables and values both settle the race.

The timer must die when the work wins

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

function withTimeout(work, ms, { errorFactory } = {}) {
  let timerId;
  const timeout = new Promise((_, reject) => {
    timerId = setTimeout(() => {
      reject(errorFactory ? errorFactory() : new TimeoutError(ms));
    }, ms);
  });

  return Promise.race([Promise.resolve(work), timeout]).finally(() => {
    clearTimeout(timerId);
  });
}
await withTimeout(fetch('/api/fast').then((r) => r.json()), 2000);

Why Promise.resolve(work): race on a non-thenable fulfills immediately with that value. Callers pass promises, thenables, or (rarely) sync values. resolve normalizes them.

Why finally and not .then only on success: the timer must die when work rejects too. Otherwise a fast failure still schedules a timeout callback a few seconds later.

What goes wrong without clearTimeout

  1. Work fulfills at 10ms, timeout is 5s.
  2. race fulfills with the work. Both inputs already have handlers, so a later timer reject is ignored, not unhandled.
  3. At 5s the timer callback still runs, constructs a TimeoutError, and rejects a promise nobody cares about.
  4. Until then the timer keeps the callback (and anything it closed over) alive.

clearTimeout stops step 3. It does not stop work if the timer won. That is the next section.

Race is not AbortController

Tool What it does What it does not do
Promise.race Settle with the first settlement (fulfill or reject) Stop the other side
clearTimeout Cancel the timer callback Cancel fetch / disk / CPU
AbortController.abort() Signal cooperative cancel; fetch actually drops the request Magically stop a raw Promise that ignores the signal
// Still running after the throw:
await withTimeout(fetch('/api/slow'), 100);

The network continues, JSON parsing continues, and if something else awaits that same fetch (you shared the promise), it can still resolve. For search boxes that is the stale-response bug.

To actually cancel:

function withTimeoutAbort(factory, ms, { signal: parent } = {}) {
  const controller = new AbortController();
  const onParent = () => controller.abort(parent.reason);
  parent?.addEventListener('abort', onParent);

  const timerId = setTimeout(() => {
    controller.abort(new TimeoutError(ms));
  }, ms);

  const work = Promise.resolve().then(() => factory(controller.signal));

  return work.finally(() => {
    clearTimeout(timerId);
    parent?.removeEventListener('abort', onParent);
  });
}

await withTimeoutAbort(
  (signal) => fetch('/api/slow', { signal }).then((r) => r.json()),
  2000,
);

Here the deadline aborts. fetch rejects with AbortError (or your TimeoutError if you passed it as abort reason). The timer only calls abort; there is no second racer. You still clearTimeout if work finishes first, or the abort would fire late and surprise a reused controller. If factory ignores signal, abort does nothing and the helper can hang; race at least rejects, which is why interviews start there.

Platform shortcut, same idea: fetch(url, { signal: AbortSignal.timeout(2000) }). Interviews still want race + clearTimeout because it tests combinators. Production fetch should prefer the signal. See AbortController and Promise combinators.

Empty race and hang myths

Promise.race([]) never settles. Your helper always passes two entries, so that hang is a Promise.race polyfill topic, not this one.

race rejects if the first settlement is a rejection. A slow success does not recover a fast timeout. Order of fulfillment in all is a different combinator.

Errors you should distinguish

try {
  await withTimeout(doWork(), 1000);
} catch (err) {
  if (err.name === 'TimeoutError') {
    show('That took too long');
    return;
  }
  throw err;
}

Do not map every reject to “timeout.” Network TypeError, HTTP errors you threw, and abort from a parent signal need different UI. A custom TimeoutError (or DOMException with name 'TimeoutError') keeps the branch obvious.

If doWork() throws synchronously, Promise.resolve(work) does not catch it (work never returns). Call withTimeout(() => doWork(), ms) and invoke inside Promise.resolve().then(...) if you need sync throws to become rejections.

Procedure

  1. Start a timer that rejects with a dedicated error.
  2. Promise.race([Promise.resolve(work), timeout]).
  3. finally { clearTimeout(id) } on both success and failure.
  4. Say out loud: the loser keeps running.
  5. If the work is fetch, take a factory and AbortController (or AbortSignal.timeout).

Interview out-loud answer

I race the work against a setTimeout reject and clear the timer in finally so a fast success does not leave a 5s callback holding memory. Promise.race already handles both sides, so this is timer lifetime, not unhandledrejection. race does not cancel the loser, so a timed-out fetch is still in flight unless I abort. I would wrap work in Promise.resolve and throw a TimeoutError so the caller can tell deadline from HTTP failure. AbortSignal.timeout is the platform version I would use in an app.

Further reading

Related guides