Timeout a Promise
A promise that never settles will leak handles and stall UI flows; wrapping it with a deadline turns a bug into a recoverable error. This snippet shows the classic `Promise.race` pattern, then upgrades it to clean up the timer on success and to forward an `AbortSignal` so cancelled work stops doing real I/O. Use it around any `fetch`, DB call, or third-party SDK that does not expose a native timeout option.
737 views
15
function withTimeout(promise, ms, message) {
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error(message || `Timed out after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]);
}
const slow = new Promise((resolve) => setTimeout(() => resolve('done'), 100));
withTimeout(slow, 30, 'fetch deadline').catch((err) => console.log(err.message));Promise.race settles with whichever input promise settles first, so racing the real work against a setTimeout rejection gives you a deadline. The rejection message becomes the error your callers see, so make it descriptive (which call timed out, and how long was the budget). The downside of this minimal version is that the timer keeps running even after the underlying promise resolves, briefly delaying process exit in scripts and leaking timers in long-lived servers. Reach for the cleanup variant in production code.
function withTimeout(promise, ms, message) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(message || `Timed out after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
const fast = new Promise((resolve) => setTimeout(() => resolve(42), 10));
withTimeout(fast, 100).then((v) => console.log('value:', v));The .finally(() => clearTimeout(timer)) clears the pending timer once the race settles either way, so a fast resolve no longer leaves a dangling timeout that can keep the Node event loop alive. This matters for command-line scripts that wait for the loop to drain before exiting, and for tests that assert no leaked timers. The behavior on the failing path is unchanged: when the timer fires first, the underlying promise still runs to completion, just with its result discarded.
function withTimeoutAbort(work, ms, message) {
const ac = new AbortController();
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => {
ac.abort();
reject(new Error(message || `Timed out after ${ms}ms`));
}, ms);
});
return Promise.race([work(ac.signal), timeout]).finally(() => clearTimeout(timer));
}
function fakeFetch(signal) {
return new Promise((resolve, reject) => {
const id = setTimeout(() => resolve('payload'), 200);
signal.addEventListener('abort', () => {
clearTimeout(id);
reject(new Error('aborted'));
}, { once: true });
});
}
withTimeoutAbort(fakeFetch, 30).catch((err) => console.log(err.message));Racing two promises only stops listening for the loser; the actual network or compute keeps running until it finishes. Threading an AbortSignal through the work lets the timeout abort the underlying request when the deadline fires, freeing sockets and CPU instead of just dropping the result on the floor. The pattern composes cleanly with fetch(url, { signal }) and any helper that follows the abort convention. Without this step, a chatty server can pile up zombie requests behind the user's back.
