You've scheduled work to run later with setTimeout, then realised — before it fires — that you no longer need it. A toast that should hide after 3 seconds, but the user dismissed it manually. A request retry queued for later, but the network came back. You need a handle that lets the caller say "never mind, cancel that."
Implement a cancellableTimeout function that schedules fn to run after delay milliseconds and returns a cancel function. Calling cancel() before the timer fires must prevent fn from running. Calling it after the timer has already fired is a no-op.
function cancellableTimeout(fn, delay) {
// schedules fn() to run after `delay` ms
// returns a cancel function: calling it cancels the pending fn
}
const cancel = cancellableTimeout(() => console.log('ran'), 100);
// nothing yet
// after 100ms: logs 'ran'
const cancel = cancellableTimeout(() => console.log('ran'), 100);
cancel();
// after 100ms: nothing — cancel prevented the call
setTimeout and clearTimeout — no external libraries, no polling with Date.now().cancel() after fire is a no-op — it must not throw and must not call fn again.cancel() called twice is also a no-op on the second call.fn takes no arguments for v1 — you don't need to forward args or preserve this.You'll wrap setTimeout so the caller gets back a handle they can use to cancel the pending callback before it fires.
Picture a toast notification that auto-dismisses after 3 seconds. You schedule the dismiss with setTimeout. Then the user clicks the close button at second 1 — you want to dismiss it now, and you don't want the auto-dismiss firing later on top of that. You need a way to cancel the timer you scheduled.
A timer in JavaScript has two lifetimes: scheduled (waiting to fire) and fired (already ran). cancellableTimeout gives you a button that only does something during the first phase. If you press it after the timer fires, nothing happens — there's nothing left to cancel.
A reasonable first try is to schedule the timer and return clearTimeout bound to its id:
function cancellableTimeout(fn, delay) {
const id = setTimeout(fn, delay);
return () => clearTimeout(id);
}
This works for the happy path — cancel() before the timer fires does prevent fn from running. The gap is what happens after the timer fires: there's no way to know the timer is already gone. clearTimeout on a stale id is harmless today, but the moment you add an onCancel hook you'll fire it on a no-op cancel.
Track whether the timer is still pending. Flip the flag when it fires, and let cancel short-circuit once the flag is off:
function cancellableTimeout(fn, delay) {
let id = setTimeout(() => {
id = null; // set before fn() in case fn calls cancel()
fn();
}, delay);
return function cancel() {
if (id === null) return; // already fired or already cancelled
clearTimeout(id);
id = null;
};
}
module.exports = { cancellableTimeout };
A closure — a function that remembers variables from its surrounding scope (MDN) — lets the returned cancel function read and update the same id across calls. That one closed-over variable doubles as a state flag: non-null means "pending, cancel will work," null means "done, leave it alone."
Notice we set id = null before calling fn(). This order is critical: if fn() itself calls cancel(), we need id to already be null, otherwise cancel would find a live id and attempt a second clearTimeout on a timer that's already gone.
The single check id === null protects two paths: the timer already fired (the main thread set it to null inside the callback) and the user already called cancel once (cancel set it to null on the first call). Both leave id null, so the second call is a true no-op.
Say delay = 100:
cancellableTimeout(fn, 100). setTimeout returns id 42. id = 42. You get back a cancel function.cancel(). id is non-null, so clearTimeout(42) runs and id is set to null.cancel() is called again (maybe from a cleanup hook). id is null, so the function returns immediately.id after fire — say you later add an onCancel hook: function cancellableTimeout(fn, delay, onCancel) { let id = setTimeout(() => { fn(); }, delay); return () => { if (id !== null) { clearTimeout(id); onCancel(); } }; }. Because the callback never nulls id, calling cancel() after the timer has fired will incorrectly trigger onCancel() — the timer is already gone but the guard fails because id is still non-null.fn() before clearing id — if fn itself calls cancel(), id would still point at the just-fired timer. Set id = null first, then call fn.clearTimeout. Return a function that does it for them.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
You've scheduled work to run later with setTimeout, then realised — before it fires — that you no longer need it. A toast that should hide after 3 seconds, but the user dismissed it manually. A request retry queued for later, but the network came back. You need a handle that lets the caller say "never mind, cancel that."
Implement a cancellableTimeout function that schedules fn to run after delay milliseconds and returns a cancel function. Calling cancel() before the timer fires must prevent fn from running. Calling it after the timer has already fired is a no-op.
function cancellableTimeout(fn, delay) {
// schedules fn() to run after `delay` ms
// returns a cancel function: calling it cancels the pending fn
}
const cancel = cancellableTimeout(() => console.log('ran'), 100);
// nothing yet
// after 100ms: logs 'ran'
const cancel = cancellableTimeout(() => console.log('ran'), 100);
cancel();
// after 100ms: nothing — cancel prevented the call
setTimeout and clearTimeout — no external libraries, no polling with Date.now().cancel() after fire is a no-op — it must not throw and must not call fn again.cancel() called twice is also a no-op on the second call.fn takes no arguments for v1 — you don't need to forward args or preserve this.You'll wrap setTimeout so the caller gets back a handle they can use to cancel the pending callback before it fires.
Picture a toast notification that auto-dismisses after 3 seconds. You schedule the dismiss with setTimeout. Then the user clicks the close button at second 1 — you want to dismiss it now, and you don't want the auto-dismiss firing later on top of that. You need a way to cancel the timer you scheduled.
A timer in JavaScript has two lifetimes: scheduled (waiting to fire) and fired (already ran). cancellableTimeout gives you a button that only does something during the first phase. If you press it after the timer fires, nothing happens — there's nothing left to cancel.
A reasonable first try is to schedule the timer and return clearTimeout bound to its id:
function cancellableTimeout(fn, delay) {
const id = setTimeout(fn, delay);
return () => clearTimeout(id);
}
This works for the happy path — cancel() before the timer fires does prevent fn from running. The gap is what happens after the timer fires: there's no way to know the timer is already gone. clearTimeout on a stale id is harmless today, but the moment you add an onCancel hook you'll fire it on a no-op cancel.
Track whether the timer is still pending. Flip the flag when it fires, and let cancel short-circuit once the flag is off:
function cancellableTimeout(fn, delay) {
let id = setTimeout(() => {
id = null; // set before fn() in case fn calls cancel()
fn();
}, delay);
return function cancel() {
if (id === null) return; // already fired or already cancelled
clearTimeout(id);
id = null;
};
}
module.exports = { cancellableTimeout };
A closure — a function that remembers variables from its surrounding scope (MDN) — lets the returned cancel function read and update the same id across calls. That one closed-over variable doubles as a state flag: non-null means "pending, cancel will work," null means "done, leave it alone."
Notice we set id = null before calling fn(). This order is critical: if fn() itself calls cancel(), we need id to already be null, otherwise cancel would find a live id and attempt a second clearTimeout on a timer that's already gone.
The single check id === null protects two paths: the timer already fired (the main thread set it to null inside the callback) and the user already called cancel once (cancel set it to null on the first call). Both leave id null, so the second call is a true no-op.
Say delay = 100:
cancellableTimeout(fn, 100). setTimeout returns id 42. id = 42. You get back a cancel function.cancel(). id is non-null, so clearTimeout(42) runs and id is set to null.cancel() is called again (maybe from a cleanup hook). id is null, so the function returns immediately.id after fire — say you later add an onCancel hook: function cancellableTimeout(fn, delay, onCancel) { let id = setTimeout(() => { fn(); }, delay); return () => { if (id !== null) { clearTimeout(id); onCancel(); } }; }. Because the callback never nulls id, calling cancel() after the timer has fired will incorrectly trigger onCancel() — the timer is already gone but the guard fails because id is still non-null.fn() before clearing id — if fn itself calls cancel(), id would still point at the just-fired timer. Set id = null first, then call fn.clearTimeout. Return a function that does it for them.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.