You add a deadline to a request with Promise.race([work(), rejectAfter(5000)]). When the timeout branch wins, what actually happens to the work promise and to the pending setTimeout, and what problems does that cause in a long-running service?
answer
- the race ends, the work does not
- promises model results, not work
- losers keep their side effects
- uncleared timer on the success path
- losing rejections stay silent
basics
~20 sNothing stops. The race rejects, but the work promise keeps running to completion with its result discarded, its side effects still landing, and the timer stays scheduled whenever the work wins instead. Promise combinators end the wait, not the work.
solid answer
~40 sRacing against a timer only decides when *you* stop waiting. The losing `work()` promise is untouched: its request completes, its callbacks run, any writes or retries it triggers still happen, and its resolved value is thrown away because a settled promise cannot settle twice. Under load that means in-flight work accumulates past the timeout — connections, buffers and rate-limit quota all held by requests nobody is waiting for. The mirror-image leak is the timer: when the work wins, the `setTimeout` is still scheduled and, in Node, keeps the process alive until it fires, so clear it in a `.finally()`. Real cancellation has to come from the operation itself through an abort mechanism; no combinator built out of promises can provide it.
code
javascript · 18 linesfunction withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
const work = new Promise((resolve) =>
setTimeout(() => {
console.log('work finished anyway');
resolve('done');
}, 50),
);
withTimeout(work, 10).catch((e) => console.log(e.message));
// timed out after 10ms
// work finished anywaygo deeper
Understand the core fact before anything else: a promise represents a result, so racing against a timer only decides when your code stops waiting — the other operation keeps going.
Explain the mechanics on both paths: the loser resolving a settled promise is a no-op, and the timer is still scheduled when the work wins, so clear it in a finally that passes the outcome through.
Diagnose the production shape — abandoned in-flight work piling up against a connection pool, late writes overwriting fresher values, and dependency failures made invisible because the combinator already handled them.
Own where the deadline belongs. Argue when a client-side cutoff is enough versus when the server must enforce its own deadline, and weigh the load a policy of abandoning slow work imposes on every dependency downstream.
## The mental model to correct first A promise is a handle on a **result**, not a handle on the **work**. `Promise.race` composes results. So when the timeout branch wins, the only thing that changed is which value your `await` receives. The work carries on exactly as it would have, and the runtime has no notion that anyone lost interest. ```javascript const timeout = (ms) => new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)); try { await Promise.race([work(), timeout(50)]); } catch (e) { console.log(e.message); // timeout — and work() is still running } ``` When `work()` eventually resolves, it calls the race's resolve function, which is a no-op on an already-settled promise. Silent, invisible, and completely normal. ## Consequence 1: side effects still land Discarding a value is not the same as undoing an action. If `work()` was a `POST` that charges a card, the charge happens after your timeout error. If it retries internally, the retries continue. If it writes to a cache or a database, the write lands — possibly after a fallback path already wrote a different value, which is a real ordering bug: the timed-out operation overwrites the fresher one. Any timeout built this way must be paired with the question "is the underlying operation safe to abandon mid-flight?" ## Consequence 2: resources stay held Under steady load, timing out at 5 s while the dependency takes 30 s means roughly six generations of abandoned requests coexist. Each holds a socket from the connection pool, buffers, and whatever memory its closure captures. The pool exhausts, healthy fast requests start queuing behind ghosts, and the symptom presents as a total slowdown rather than as a timeout problem. The tell in a heap snapshot or a socket count is in-flight work far exceeding concurrent callers. ## Consequence 3: the timer leaks the other way The symmetric bug is the one people forget, because it happens on the **success** path. If `work()` wins in 10 ms, the 5-second `setTimeout` is still scheduled. In Node a pending timer keeps the event loop alive, so a short-lived script or a test run hangs for the full timeout after its work is done; at high request rates you also accumulate thousands of live timer entries. Always clear it: ```javascript function withTimeout(promise, ms) { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } ``` `.finally()` runs on both paths and passes the settlement through unchanged, which is exactly the semantics you want here. ## The nuance interviewers probe: unhandled rejections If the abandoned `work()` promise rejects *after* the race settled, does that produce an unhandled rejection? **No.** `Promise.race` attached both handlers to every entry, so the rejection is delivered to the race's reject function — a no-op on a settled promise, but delivered, and therefore handled. No warning, no `unhandledrejection` event. That cuts both ways. It keeps the pattern quiet, but it also means a loser failing loudly (a connection reset, a 500) disappears entirely: no log, no metric, no trace of a dependency that is broken rather than slow. If you care about that signal, attach your own `.catch` to the work promise before racing it — and rethrow, so the race still sees the rejection if it is still pending. ## What actually cancels Only the operation itself can stop. The pattern is to give `work()` an abort mechanism and trigger it on the timeout path, so the underlying request is genuinely torn down rather than merely ignored. Even then, cancellation is best-effort: a request already delivered to a server will be processed regardless of whether the client is still listening, so the server needs its own deadline. A client-side timeout bounds *your* latency; it never bounds the dependency's work. ## How to answer this well Say the two directions of the leak (abandoned work, uncleared timer), name the invisible-rejection consequence, and finish with the honest limit: a promise combinator can only end the wait. Candidates who claim `race` "cancels the slower promise" have not operated a service that timed out under load.
- Why does the abandoned promise rejecting after the timeout not produce an unhandled rejection warning?Because `Promise.race` subscribed to it. Every entry receives the race's resolve and reject functions as handlers, so the entry's rejection is considered handled even though calling reject on an already-settled promise does nothing. The upside is silence; the downside is that a genuinely broken dependency leaves no log or metric unless you attach your own catch — which should rethrow so the race still sees it.
- What goes wrong if you forget the clearTimeout in the success path?The timer stays scheduled for the full duration. In Node a pending timer keeps the event loop alive, so a script or test run hangs after its work is finished, and under load you accumulate one live timer per request until each fires. Putting `clearTimeout` in a `.finally()` covers both settlement paths and passes the outcome through unchanged.
- Your timeout fires but the dependency completes 20 seconds later and writes to the cache. How do you reason about that?Treat the abandoned operation as still live: it can commit writes after your fallback already produced a different answer, so the stale value can overwrite the fresh one. Either make the operation genuinely abortable so it stops, make its write idempotent and version-stamped so a late write loses, or push the deadline to the server so nobody performs work no caller wants.
saying these in an interview costs you the question
- Says the race aborts the losing request automatically
- Assumes the pending setTimeout is garbage collected
- Expects an unhandled rejection from the losing promise
- Thinks a client timeout stops the server's work
- Believes discarding the value undoes the side effects