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.
- 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
withTimeoutraces work against a timer andclearTimeoutinfinally, whichever side wins.clearTimeoutis for timer lifetime, not forunhandledrejection.racealready handled both inputs. Clearing stops a useless macrotask and a thrownTimeoutErrornobody will read.Promise.racedoes not cancel the loser. Pair a timeout withAbortController(orAbortSignal.timeout) if the work isfetch.- Wrap
workwithPromise.resolveso 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
- Work fulfills at 10ms, timeout is 5s.
racefulfills with the work. Both inputs already have handlers, so a later timer reject is ignored, not unhandled.- At 5s the timer callback still runs, constructs a
TimeoutError, and rejects a promise nobody cares about. - 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
- Start a timer that rejects with a dedicated error.
Promise.race([Promise.resolve(work), timeout]).finally { clearTimeout(id) }on both success and failure.- Say out loud: the loser keeps running.
- If the work is
fetch, take a factory andAbortController(orAbortSignal.timeout).
Interview out-loud answer
I race the work against a
setTimeoutreject and clear the timer infinallyso a fast success does not leave a 5s callback holding memory.Promise.racealready handles both sides, so this is timer lifetime, notunhandledrejection.racedoes not cancel the loser, so a timed-outfetchis still in flight unless I abort. I would wrapworkinPromise.resolveand throw aTimeoutErrorso the caller can tell deadline from HTTP failure.AbortSignal.timeoutis the platform version I would use in an app.
Related on this site
- Promises:
racevsallvsany - AbortController: real cancel,
AbortSignal.timeout - Promise.race Polyfill: first settlement, empty iterable hang
- Retry a Promise N Times: replay vs deadline
- Promise combinators practice: timeout pattern in a drill sheet
- The Event Loop: timers are macrotasks, promise reactions are microtasks
- Fetch API Fundamentals: passing
signal
Further reading
Related guides
- Run Async Tasks in ParallelStart every task now, wait with Promise.all, and talk about the wall-clock vs series. Error handling and why unbounded parallel is a production bug.
- Run Async Tasks in SeriesExecute async functions one after another, collect results in order, and stop on first rejection. Reduce vs for-await, and when series is the point.
- Custom Promise From ScratchBuild a tiny Promise: executor, then chaining, microtask reactions, resolve-thenable unwrapping, and the states you are not allowed to leave.
- mapLimit: Async Map With a Concurrency CapMap an array through an async iteratee with at most N in flight. Preserve output order, fail fast, and drain the queue without starving later items.
- Promise.all PolyfillImplement Promise.all the way interviews score it: order-preserving results, fail-fast reject, and thenable wrapping with Promise.resolve.