Implement promiseTimeout(promise, ms) — a wrapper that gives any pending promise a deadline. If the input settles within ms milliseconds, the returned promise settles with the same outcome (fulfilment or rejection). If the deadline arrives first, the returned promise rejects with a timeout Error. It's the standard composition you reach for whenever an async operation might hang — a slow fetch, a stalled database call, a flaky third-party SDK.
The exercise is short on lines but easy to get subtly wrong: the timer must be cancelled when the input wins, or you leak a setTimeout that fires after the work is done and produces a stray rejection no one is listening for.
// Wraps `promise` with a deadline of `ms` milliseconds.
// Resolves/rejects with the input's outcome if it settles first;
// rejects with an Error if `ms` elapses first.
function promiseTimeout<T>(promise: T | PromiseLike<T>, ms: number): Promise<T>;
// Promise resolves before the deadline — passes through.
const fast = new Promise((r) => setTimeout(() => r('hello'), 20));
promiseTimeout(fast, 100).then(console.log); // 'hello'
// Promise is slower than the deadline — rejects with a timeout Error.
const slow = new Promise((r) => setTimeout(() => r('hello'), 200));
promiseTimeout(slow, 50).catch((err) => console.log(err.message));
// 'Promise timed out after 50ms'
// Promise rejects in time — the rejection passes through unchanged.
const bad = new Promise((_, reject) => setTimeout(() => reject('boom'), 10));
promiseTimeout(bad, 100).catch(console.log); // 'boom'
instanceof Promise. Don't return the input directly, even when it would settle in time.Error. A descriptive message like 'Promise timed out after 50ms' is expected. String rejections are a common mistake — they lose the stack.promise settles before ms, you must clearTimeout the deadline timer. Leaving it scheduled leaks a handle and fires a late rejection that no .catch is listening for.42 or 'hello' should be wrapped through Promise.resolve so the same code path handles every case — including thenables.ms === 0 is allowed. An already-resolved input still wins the race (microtask beats macrotask). Don't special-case zero.AbortSignal.timeout or libraries. setTimeout, clearTimeout, Promise.race, and Promise.resolve are all you need.You'll write a small wrapper that lets any promise race a stopwatch — if the work settles in time, you pass its result through; if the stopwatch hits zero first, you reject with a timeout error.
Your app calls a flaky API. Most of the time it answers in under 100ms, but once in a while it just hangs — and your UI sits there, spinner spinning, with no way to recover. You want a fence around that call: settle within ms milliseconds with whatever the API said, or give up and reject with Error('Promise timed out after Xms') so the calling code can show a friendly retry button. That fence is promiseTimeout(promise, ms). The whole pattern is built from two primitives: Promise.race (which settles with whichever input settles first) and a setTimeout that rejects a side promise when it fires.
Picture two horizontal tracks on a time axis. The top track is promise — whatever async work you handed in. The bottom track is timer — a promise we make ourselves that auto-rejects after ms ms. We hand both to Promise.race, which acts like a referee: it watches both lanes and declares whichever one crosses the finish line first. The other track keeps running, but its result is ignored — unless we forget to clean it up, in which case it becomes garbage with side effects.
The shortest possible solution looks great in a code review:
function naive(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
]);
}
It's a one-liner. It even passes the basic "resolves in time" and "times out" tests. But run it on a fast-resolving input and watch the process:
const fast = new Promise((r) => setTimeout(() => r('done'), 20));
naive(fast, 50).then(console.log); // 'done' at 20ms ✓
// ...50ms later, the inner setTimeout fires and calls reject(new Error('timeout'))
// on a promise nothing is listening to. Node prints:
// (node:1234) UnhandledPromiseRejection: Error: timeout
Two problems. First, the timer is never cancelled — Promise.race only watches which promise settles first; it doesn't reach into the loser and stop its work. The setTimeout handle stays alive in the event loop until it fires. Second, when it does fire, the inner executor calls reject(new Error('timeout')) on the timer promise. Race ignored it (race already settled with 'done'), but the timer promise itself is now a rejected promise with no .catch attached — which Node reports as an unhandled rejection. On a server that's noise in your logs; in a browser it's a window.onunhandledrejection event. Both are real footguns that ship to production unnoticed.
function promiseTimeout(promise, ms) {
// Capture the timer handle in the enclosing scope so finally() can clear it.
// We can't declare it inside the timer executor because we need to reach
// setTimeout's return value from outside that closure.
let timerId;
// Build the side promise that rejects when the deadline hits. The executor
// runs synchronously inside `new Promise`, so timerId is assigned before
// the constructor returns — by the time the race attaches, we already have
// a handle to clear.
const timer = new Promise((_, reject) => {
timerId = setTimeout(
() => reject(new Error(`Promise timed out after ${ms}ms`)),
ms,
);
});
// Promise.resolve(promise) handles three input shapes uniformly:
// - a real Promise: returned as-is
// - a thenable: wrapped into a real Promise
// - a plain value: wrapped into an already-resolved Promise
// Without it, passing in `42` would make Promise.race throw at runtime.
//
// .finally() runs on BOTH outcomes (fulfilment and rejection). Using .then()
// alone would skip the cleanup branch when the race rejects; chaining a
// separate .then + .catch would duplicate the call. finally is the one
// combinator that says "run this no matter what, then pass through the
// original outcome."
return Promise.race([Promise.resolve(promise), timer]).finally(() => {
clearTimeout(timerId);
});
}
module.exports = { promiseTimeout };
Three shifts from the naive version. First, the timer id leaves the executor — let timerId in the enclosing scope, assigned by the synchronous setTimeout call inside the new Promise. Second, Promise.resolve(promise) normalises the input so plain values and thenables go through the same path as real promises. Third, .finally(() => clearTimeout(timerId)) runs on every outcome — including the timeout-reject case, where it's a no-op (the timer already fired) but harmless. The combination guarantees that no timer outlives the race.
Trace promiseTimeout(slow(80, 'hello'), 50), where slow(ms, value) returns a promise that resolves to value after ms milliseconds.
Synchronous phase (t = 0). We enter promiseTimeout. let timerId is declared but undefined. We construct timer = new Promise(...). The executor runs synchronously: setTimeout(..., 50) is scheduled and its return value is stored in timerId. timer is now a pending Promise that will reject in 50ms. We then call Promise.race([Promise.resolve(slowPromise), timer]). Promise.resolve(slowPromise) returns slowPromise itself (it's already a real Promise). Race attaches .then callbacks to both inputs and returns a new pending Promise. We chain .finally(...) on the race result and return that final promise. promiseTimeout has returned; the function is done executing.
t = 50ms. The browser's timer queue ticks. Our scheduled callback runs: reject(new Error('Promise timed out after 50ms')). The timer promise transitions to rejected. The race's listener on timer fires — race itself transitions to rejected with the same Error. The .finally(...) callback then runs: clearTimeout(timerId) is called on a timer that already fired, which is a no-op. The final returned promise rejects with the timeout Error.
t = 80ms. slowPromise resolves with 'hello'. Race's listener on Promise.resolve(slowPromise) fires — but race has already settled at t=50, so this second settlement is ignored. The 'hello' value goes nowhere.
Now swap the deadline: promiseTimeout(slow(80, 'hello'), 100). Same setup at t=0, but the timer is scheduled for t=100. At t=80, slowPromise resolves with 'hello'. Race fulfils with 'hello'. .finally(...) runs: clearTimeout(timerId) cancels the pending timer before t=100. The browser removes it from the queue. The final promise resolves with 'hello'. No stray rejection at t=100 because the timer never fires.
That second trace — the "fast resolve, timer cancelled" path — is the whole reason clearTimeout exists in this function. Without it, the timer at t=100 would still fire and reject timer, producing the unhandled-rejection warning we saw in the naive version.
clearTimeout on the success path. The race still picks the right winner — but the timer keeps ticking and emits a stray rejection on the side promise. In Node you'll see UnhandledPromiseRejection in your logs; in the browser you'll see it on window.onunhandledrejection. The fix is one line: .finally(() => clearTimeout(timerId)). Use finally, not then, so it runs whether the race fulfils or rejects.Error. reject('timeout') works, but the catcher loses the stack and can't instanceof Error-check the failure. Always use new Error('Promise timed out after Xms') — descriptive message in the string, real Error type for the consumer to discriminate. Tests in this question check both.Promise.resolve(promise). If a caller passes a thenable or a plain value, Promise.race([42, timer]) works (race wraps non-promises automatically), but the same code in another shape — say a manual .then on the input — would crash with TypeError: 42.then is not a function. Wrapping with Promise.resolve once at the top is cheap insurance that the same code path handles every input shape.ms === 0 doesn't mean "instant reject". setTimeout(..., 0) schedules a macrotask. An already-resolved input promise (or a Promise.resolve(value) wrapper) settles its .then callback on the microtask queue, which drains before the next macrotask. So promiseTimeout(Promise.resolve('x'), 0) resolves with 'x', not a timeout. That's usually the right thing — but if your caller passes 0 to mean "fail immediately", document that this isn't what your function does.timerId inside the executor. new Promise((_, reject) => { const timerId = setTimeout(...); }) looks tidier but traps the id in a scope finally can't see. Hoist let timerId to the function body.cancel() function. Internally, cancel() calls clearTimeout(timerId) and rejects the returned promise with a CancelledError. The same finally cleanup still runs. Useful when the caller's situation changes mid-flight — e.g. the user navigates away from a page that's waiting on a request.AbortSignal integration. Accept an optional { signal } option. When the signal aborts, call clearTimeout(timerId) and reject with signal.reason (usually a DOMException named 'AbortError'). This composes with fetch, which natively accepts a signal — wrap a fetch(url, { signal }) with promiseTimeout(p, 5000, { signal: userAbort }) and either user-cancel or server-slowness will resolve the same way to the caller.class TimeoutError extends Error { name = 'TimeoutError' } lets consumers write if (err instanceof TimeoutError) instead of pattern-matching the message string. Cheap, and friendlier to future refactors of the message.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Implement promiseTimeout(promise, ms) — a wrapper that gives any pending promise a deadline. If the input settles within ms milliseconds, the returned promise settles with the same outcome (fulfilment or rejection). If the deadline arrives first, the returned promise rejects with a timeout Error. It's the standard composition you reach for whenever an async operation might hang — a slow fetch, a stalled database call, a flaky third-party SDK.
The exercise is short on lines but easy to get subtly wrong: the timer must be cancelled when the input wins, or you leak a setTimeout that fires after the work is done and produces a stray rejection no one is listening for.
// Wraps `promise` with a deadline of `ms` milliseconds.
// Resolves/rejects with the input's outcome if it settles first;
// rejects with an Error if `ms` elapses first.
function promiseTimeout<T>(promise: T | PromiseLike<T>, ms: number): Promise<T>;
// Promise resolves before the deadline — passes through.
const fast = new Promise((r) => setTimeout(() => r('hello'), 20));
promiseTimeout(fast, 100).then(console.log); // 'hello'
// Promise is slower than the deadline — rejects with a timeout Error.
const slow = new Promise((r) => setTimeout(() => r('hello'), 200));
promiseTimeout(slow, 50).catch((err) => console.log(err.message));
// 'Promise timed out after 50ms'
// Promise rejects in time — the rejection passes through unchanged.
const bad = new Promise((_, reject) => setTimeout(() => reject('boom'), 10));
promiseTimeout(bad, 100).catch(console.log); // 'boom'
instanceof Promise. Don't return the input directly, even when it would settle in time.Error. A descriptive message like 'Promise timed out after 50ms' is expected. String rejections are a common mistake — they lose the stack.promise settles before ms, you must clearTimeout the deadline timer. Leaving it scheduled leaks a handle and fires a late rejection that no .catch is listening for.42 or 'hello' should be wrapped through Promise.resolve so the same code path handles every case — including thenables.ms === 0 is allowed. An already-resolved input still wins the race (microtask beats macrotask). Don't special-case zero.AbortSignal.timeout or libraries. setTimeout, clearTimeout, Promise.race, and Promise.resolve are all you need.You'll write a small wrapper that lets any promise race a stopwatch — if the work settles in time, you pass its result through; if the stopwatch hits zero first, you reject with a timeout error.
Your app calls a flaky API. Most of the time it answers in under 100ms, but once in a while it just hangs — and your UI sits there, spinner spinning, with no way to recover. You want a fence around that call: settle within ms milliseconds with whatever the API said, or give up and reject with Error('Promise timed out after Xms') so the calling code can show a friendly retry button. That fence is promiseTimeout(promise, ms). The whole pattern is built from two primitives: Promise.race (which settles with whichever input settles first) and a setTimeout that rejects a side promise when it fires.
Picture two horizontal tracks on a time axis. The top track is promise — whatever async work you handed in. The bottom track is timer — a promise we make ourselves that auto-rejects after ms ms. We hand both to Promise.race, which acts like a referee: it watches both lanes and declares whichever one crosses the finish line first. The other track keeps running, but its result is ignored — unless we forget to clean it up, in which case it becomes garbage with side effects.
The shortest possible solution looks great in a code review:
function naive(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
]);
}
It's a one-liner. It even passes the basic "resolves in time" and "times out" tests. But run it on a fast-resolving input and watch the process:
const fast = new Promise((r) => setTimeout(() => r('done'), 20));
naive(fast, 50).then(console.log); // 'done' at 20ms ✓
// ...50ms later, the inner setTimeout fires and calls reject(new Error('timeout'))
// on a promise nothing is listening to. Node prints:
// (node:1234) UnhandledPromiseRejection: Error: timeout
Two problems. First, the timer is never cancelled — Promise.race only watches which promise settles first; it doesn't reach into the loser and stop its work. The setTimeout handle stays alive in the event loop until it fires. Second, when it does fire, the inner executor calls reject(new Error('timeout')) on the timer promise. Race ignored it (race already settled with 'done'), but the timer promise itself is now a rejected promise with no .catch attached — which Node reports as an unhandled rejection. On a server that's noise in your logs; in a browser it's a window.onunhandledrejection event. Both are real footguns that ship to production unnoticed.
function promiseTimeout(promise, ms) {
// Capture the timer handle in the enclosing scope so finally() can clear it.
// We can't declare it inside the timer executor because we need to reach
// setTimeout's return value from outside that closure.
let timerId;
// Build the side promise that rejects when the deadline hits. The executor
// runs synchronously inside `new Promise`, so timerId is assigned before
// the constructor returns — by the time the race attaches, we already have
// a handle to clear.
const timer = new Promise((_, reject) => {
timerId = setTimeout(
() => reject(new Error(`Promise timed out after ${ms}ms`)),
ms,
);
});
// Promise.resolve(promise) handles three input shapes uniformly:
// - a real Promise: returned as-is
// - a thenable: wrapped into a real Promise
// - a plain value: wrapped into an already-resolved Promise
// Without it, passing in `42` would make Promise.race throw at runtime.
//
// .finally() runs on BOTH outcomes (fulfilment and rejection). Using .then()
// alone would skip the cleanup branch when the race rejects; chaining a
// separate .then + .catch would duplicate the call. finally is the one
// combinator that says "run this no matter what, then pass through the
// original outcome."
return Promise.race([Promise.resolve(promise), timer]).finally(() => {
clearTimeout(timerId);
});
}
module.exports = { promiseTimeout };
Three shifts from the naive version. First, the timer id leaves the executor — let timerId in the enclosing scope, assigned by the synchronous setTimeout call inside the new Promise. Second, Promise.resolve(promise) normalises the input so plain values and thenables go through the same path as real promises. Third, .finally(() => clearTimeout(timerId)) runs on every outcome — including the timeout-reject case, where it's a no-op (the timer already fired) but harmless. The combination guarantees that no timer outlives the race.
Trace promiseTimeout(slow(80, 'hello'), 50), where slow(ms, value) returns a promise that resolves to value after ms milliseconds.
Synchronous phase (t = 0). We enter promiseTimeout. let timerId is declared but undefined. We construct timer = new Promise(...). The executor runs synchronously: setTimeout(..., 50) is scheduled and its return value is stored in timerId. timer is now a pending Promise that will reject in 50ms. We then call Promise.race([Promise.resolve(slowPromise), timer]). Promise.resolve(slowPromise) returns slowPromise itself (it's already a real Promise). Race attaches .then callbacks to both inputs and returns a new pending Promise. We chain .finally(...) on the race result and return that final promise. promiseTimeout has returned; the function is done executing.
t = 50ms. The browser's timer queue ticks. Our scheduled callback runs: reject(new Error('Promise timed out after 50ms')). The timer promise transitions to rejected. The race's listener on timer fires — race itself transitions to rejected with the same Error. The .finally(...) callback then runs: clearTimeout(timerId) is called on a timer that already fired, which is a no-op. The final returned promise rejects with the timeout Error.
t = 80ms. slowPromise resolves with 'hello'. Race's listener on Promise.resolve(slowPromise) fires — but race has already settled at t=50, so this second settlement is ignored. The 'hello' value goes nowhere.
Now swap the deadline: promiseTimeout(slow(80, 'hello'), 100). Same setup at t=0, but the timer is scheduled for t=100. At t=80, slowPromise resolves with 'hello'. Race fulfils with 'hello'. .finally(...) runs: clearTimeout(timerId) cancels the pending timer before t=100. The browser removes it from the queue. The final promise resolves with 'hello'. No stray rejection at t=100 because the timer never fires.
That second trace — the "fast resolve, timer cancelled" path — is the whole reason clearTimeout exists in this function. Without it, the timer at t=100 would still fire and reject timer, producing the unhandled-rejection warning we saw in the naive version.
clearTimeout on the success path. The race still picks the right winner — but the timer keeps ticking and emits a stray rejection on the side promise. In Node you'll see UnhandledPromiseRejection in your logs; in the browser you'll see it on window.onunhandledrejection. The fix is one line: .finally(() => clearTimeout(timerId)). Use finally, not then, so it runs whether the race fulfils or rejects.Error. reject('timeout') works, but the catcher loses the stack and can't instanceof Error-check the failure. Always use new Error('Promise timed out after Xms') — descriptive message in the string, real Error type for the consumer to discriminate. Tests in this question check both.Promise.resolve(promise). If a caller passes a thenable or a plain value, Promise.race([42, timer]) works (race wraps non-promises automatically), but the same code in another shape — say a manual .then on the input — would crash with TypeError: 42.then is not a function. Wrapping with Promise.resolve once at the top is cheap insurance that the same code path handles every input shape.ms === 0 doesn't mean "instant reject". setTimeout(..., 0) schedules a macrotask. An already-resolved input promise (or a Promise.resolve(value) wrapper) settles its .then callback on the microtask queue, which drains before the next macrotask. So promiseTimeout(Promise.resolve('x'), 0) resolves with 'x', not a timeout. That's usually the right thing — but if your caller passes 0 to mean "fail immediately", document that this isn't what your function does.timerId inside the executor. new Promise((_, reject) => { const timerId = setTimeout(...); }) looks tidier but traps the id in a scope finally can't see. Hoist let timerId to the function body.cancel() function. Internally, cancel() calls clearTimeout(timerId) and rejects the returned promise with a CancelledError. The same finally cleanup still runs. Useful when the caller's situation changes mid-flight — e.g. the user navigates away from a page that's waiting on a request.AbortSignal integration. Accept an optional { signal } option. When the signal aborts, call clearTimeout(timerId) and reject with signal.reason (usually a DOMException named 'AbortError'). This composes with fetch, which natively accepts a signal — wrap a fetch(url, { signal }) with promiseTimeout(p, 5000, { signal: userAbort }) and either user-cancel or server-slowness will resolve the same way to the caller.class TimeoutError extends Error { name = 'TimeoutError' } lets consumers write if (err instanceof TimeoutError) instead of pattern-matching the message string. Cheap, and friendlier to future refactors of the message.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.