On this page
Sleep and Timeout Wrappers
Last reviewed 22 Sept 2026
Problem
Implement two small async helpers:
sleep(ms, { signal })— a promise that resolves aftermsmilliseconds, and rejects early if the signal aborts.withTimeout(promiseOrFn, ms, message?)— rejects with aTimeoutErrorif the work takes longer thanms.
await sleep(500);const user = await withTimeout(fetchUser(id), 2000); // rejects after 2 sconst data = await withTimeout((signal) => fetch(url, { signal }), 2000); // and aborts the requestClarifying questions
- Should the timeout cancel the work or only stop waiting for it? Accept a function that receives an
AbortSignal, so it can do both. - What error type? A dedicated
TimeoutErrorso callers can tell it apart. - Must the timer be cleared when the work finishes first? Yes — otherwise it keeps Node processes and tests alive.
Approach
sleep is a setTimeout wrapped in a promise, plus an abort listener that clears the timer and rejects. withTimeout races the work against a timer, then clears the timer whichever side wins. If it was given a function, it creates an AbortController, passes its signal to the function, and aborts it on timeout.
Step-by-step build
Step 1 — sleep
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));Step 2 — cancellable sleep
function sleep(ms, { signal } = {}) { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(signal.reason); const onAbort = () => { clearTimeout(timer); reject(signal.reason); }; const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); signal?.addEventListener('abort', onAbort, { once: true }); });}Step 3 — withTimeout that cleans up
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));}Step 4 — actually cancel the work
if (typeof work === 'function') { const controller = new AbortController(); // pass controller.signal to work; call controller.abort() when the timer fires}Final code
class TimeoutError extends Error { constructor(message) { super(message); this.name = 'TimeoutError'; }}
function sleep(ms, { signal } = {}) { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(signal.reason); const onAbort = () => { clearTimeout(timer); reject(signal.reason); }; const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); signal?.addEventListener('abort', onAbort, { once: true }); });}
// work: a promise, or a function (signal) => promise that should stop when signal abortsfunction withTimeout(work, ms, message = `Timed out after ${ms} ms`) { const controller = typeof work === 'function' ? new AbortController() : null; const promise = controller ? Promise.resolve().then(() => work(controller.signal)) : Promise.resolve(work); let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => { const error = new TimeoutError(message); controller?.abort(error); reject(error); }, ms); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));}Edge cases
- The work finishes first → the timer is cleared, nothing leaks.
- The work rejects first → that error is passed through, not a timeout.
ms = 0still yields to the event loop before timing out.- An already-aborted signal makes
sleepreject immediately. - Passing a plain promise can only stop the waiting; the underlying work keeps running. Say this out loud — it is the key point of the question.
Follow-ups
- Built-ins:
AbortSignal.timeout(ms)andAbortSignal.any([...])do much of this in modern runtimes; Node hastimers/promisessetTimeout. - Timeout per attempt inside retry — compose
retry(() => withTimeout(fn, 1000)). - Why does an uncleared timer matter? It keeps the Node event loop alive and makes test runners hang.
Common mistakes
- Not clearing the timer when the work finishes first.
- Rejecting with a string instead of an
Errorsubclass — no stack, noinstanceofcheck. - Claiming
Promise.racecancels the loser. It does not. - Forgetting to remove the abort listener after a normal finish.
Related
- Used by: retry with backoff, async task queue.