skip to content

A JavaScript helper adds a timeout by racing an operation against a rejecting timer: `const timeout = (ms) => new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), ms)); const data = await Promise.race([slowRequest(), timeout(1000)]);`. If slowRequest() takes 5 seconds, what does the caller observe, and what happens to slowRequest() itself?

level: juniorimportance: must knowfreq 68%

answer

  1. first settlement wins, others ignored
  2. the loser keeps running
  3. already started when race is called
  4. abandonment, not cancellation
  5. clear the timer in finally

basics

~20 s

The await rejects after one second with the timer's error, but slowRequest() keeps running to completion and its result is simply discarded. Promise.race only reports the first settlement; it cannot stop or unsubscribe from the loser.

solid answer

~50 s

`Promise.race` subscribes to every promise in the array and settles with whichever settles first, so at 1000 ms the timer's rejection wins and the caller's `await` throws `Error: timed out`. The operation itself is untouched: `slowRequest()` was already started when the array was built, it keeps holding its socket or its work until it finishes at 5 seconds, and whatever it produces is dropped on the floor. Because `race` did attach handlers to it, a late rejection from the loser counts as handled and will not surface as an unhandled rejection — but it is also invisible, which is why a raced timeout is an *abandonment*, not a cancellation. Two practical consequences: the timeout does not reduce load on the thing you timed out, and you should `clearTimeout` the timer in a `finally` so a pending timer does not linger after the winner is known.

code

javascript · 10 lines
javascript
function withTimeout(promise, ms, message = 'operation timed out') {
  let timer;
  const fuse = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(message)), ms);
  });
  return Promise.race([promise, fuse]).finally(() => clearTimeout(timer));
}

const slow = new Promise((resolve) => setTimeout(() => resolve('late'), 300));
withTimeout(slow, 100).catch((err) => console.log(err.message)); // 'operation timed out'

go deeper

for a junior

Be able to say plainly that racing against a timer changes only what your code waits for: the original call keeps running and its result is thrown away. Know that Promise.race settles with the first result, fulfilled or rejected.

for a middle

Explain the mechanics: race subscribes to every input, adopts the first settlement, and ignores the rest, so the loser's outcome is handled but invisible. Show that you clear the timer in a finally block and know why a stray timer matters in Node.

for a senior

Show the production judgment: a raced timeout protects caller latency but not dependency load, and pairing it with retries puts multiple copies of the same work in flight. Say when you would insist the operation accept a real cancellation signal instead of being abandoned.

for a principal

Own the policy question — which operations may be abandoned at all, and what a timeout error must carry so downstream retry and fallback logic can tell giving up apart from a genuine failure. Argue for one standard wrapper and error shape rather than ad-hoc races per call site.

## What the race actually does `Promise.race(iterable)` walks the iterable once, resolves each entry to a promise and attaches internal handlers to every one of them. It returns a new promise that adopts the state of whichever input settles first — fulfilled or rejected. Every later settlement is ignored, because the race promise is already settled and a promise settles exactly once. That is the whole mechanism, and it explains both halves of the answer. At 1000 ms the timer promise rejects, so the race promise rejects, so `await` throws inside the caller. At 5000 ms `slowRequest()` fulfils, the race promise is already settled, and the value goes nowhere. ```js const timeout = (ms) => new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), ms)); try { const data = await Promise.race([slowRequest(), timeout(1000)]); } catch (err) { console.log(err.message); // 'timed out' at ~1000 ms } // slowRequest() is still running here and will finish at ~5000 ms ``` ## The operation was already started A promise in JavaScript is not a description of work to be done later — it is a handle on work that has *already begun*. By the time the array literal `[slowRequest(), timeout(1000)]` is evaluated, `slowRequest()` has been called and its request is in flight. Nothing in the promise API gives a consumer a way to reach back and stop the producer, so a raced timeout can only change what the *caller* waits for. The practical consequences are worth saying out loud in an interview: - The remote server, database, or file handle is still busy. A timeout wrapper protects your caller's latency, not your dependency's load. - If the operation holds a resource — a pooled connection, a lock, a large buffer — that resource stays held until it finishes naturally. - Retrying immediately after a raced timeout means two copies of the work are now in flight, which is exactly how a slow dependency becomes an overloaded one. ## The loser's late rejection is handled, but silent A common follow-up is whether a loser that rejects later triggers an unhandled rejection. It does not: `race` attached a rejection handler to every input, so the runtime sees the rejection as handled. The downside is the opposite problem — the error vanishes entirely. If you care about diagnosing those, attach your own handler before racing: ```js const work = slowRequest(); work.catch((err) => console.warn('late failure after timeout:', err)); const data = await Promise.race([work, timeout(1000)]); ``` Note that you must race the *same* promise value you attached the handler to, not a second call to `slowRequest()`. ## Clean up the timer The naive helper leaks a pending timer whenever the operation wins the race. The callback then fires later and calls `reject` on a promise nobody is listening to — harmless in itself — but the timer is real: in Node a pending `setTimeout` keeps the event loop alive, so a script that has finished its work can sit there for the remaining seconds, and in a hot path you accumulate thousands of live timers. Clear it once the race is decided: ```js function withTimeout(promise, ms, message = 'operation timed out') { let timer; const fuse = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); }); return Promise.race([promise, fuse]).finally(() => clearTimeout(timer)); } ``` `finally` runs on both outcomes and passes the original value or rejection straight through, which is exactly the semantics a wrapper wants. ## Making the timeout mean something If the underlying work must actually stop, the operation has to cooperate: it needs to accept a signal or expose its own cancel path, and the wrapper triggers it when the deadline passes. A raced timer is the right tool only when abandoning the result is genuinely acceptable — a cheap read, a best-effort enrichment, a panel the UI can render without. When the operation is expensive or has side effects, an abandoned-but-still-running call is a bug waiting to happen. Also give the rejection a recognisable shape. `new Error('timed out')` is fine for a demo, but callers usually need to distinguish “we gave up” from “the server said no” — a dedicated error subclass or a `code` property makes the wrapper composable with logic that should treat timeouts differently from validation failures.

  • Does the abandoned operation's later rejection show up as an unhandled rejection?
    No. `Promise.race` attached its own handlers to every input, so the runtime treats a late rejection as handled. The real risk is the opposite: the failure disappears silently. If you want visibility, hold the promise in a variable, attach a `.catch` that logs, and race that same variable rather than calling the function a second time.
  • Why should the timer be cleared when the operation wins the race?
    Otherwise a pending `setTimeout` survives the decision. Its callback later rejects a promise nobody listens to — harmless — but the timer itself is live: in Node it keeps the event loop alive, so a finished script hangs until it fires, and under load the timers accumulate. Wrapping the race in `.finally(() => clearTimeout(timer))` clears it on both outcomes and passes the result through unchanged.
  • When is a raced timeout an acceptable design and when is it not?
    It is fine when abandoning the result costs nothing — a cheap read, an optional enrichment, a section the UI can render without. It is wrong when the work is expensive or has side effects, because the operation continues, keeps holding its connection or lock, and an immediate retry puts a second copy in flight. Then the operation must accept a real cancellation signal instead.

It is like leaving a restaurant after twenty minutes without your food: you stop waiting, but the kitchen is still cooking it.

saying these in an interview costs you the question

  • Claims Promise.race cancels or stops the losing promise
  • Thinks the timeout frees the connection the request was using
  • Says the abandoned operation's later rejection crashes the process
  • Leaves the setTimeout uncleared and calls the wrapper complete
  • Assumes a promise can be re-run or rolled back once created

context