You've built a basic debounce that defers a call until the user stops triggering events for wait milliseconds. In real applications you almost always need two more controls on top of that: a way to throw the pending call away, and a way to run it right now instead of waiting. Your job is to extend debounce so the returned function carries cancel() and flush() methods that do exactly that.
function debounceIi(
fn: (...args: any[]) => any,
wait: number
): {
(...args: any[]): void;
cancel(): void; // abort any pending invocation; fn is NOT called
flush(): void; // invoke any pending invocation immediately; no-op if nothing is pending
};
The returned value is a function. cancel and flush are properties attached to that function.
// 1) cancel() throws away the pending call
const d = debounceIi(console.log, 100);
d('hello');
d.cancel();
// 100ms later: nothing prints. The pending call was discarded.
// 2) flush() runs the pending call right now with the latest args
const d = debounceIi(console.log, 1000);
d('a');
d('b');
d.flush(); // prints 'b' immediately — does NOT wait the remaining ~1000ms
d.flush(); // no-op: nothing is pending anymore
d(...) until the timer fires (or cancel/flush resolves it).fn must NOT be invoked. Same after a prior flush or cancel.this — if the user attaches the debounced function as an object method (obj.save = debouncedIi(...)) and calls obj.save(), the eventual fn invocation should see obj as its this.You'll take a working debounce and bolt on two control surfaces: a cancel() that throws away a pending call, and a flush() that runs it right now.
You've already built debounce — the thing that says "wait until the user stops typing for wait ms, then fire once with the latest args." That's enough for a search box, but real apps need to steer the pending call from the outside:
You can't do either with plain debounce because the timer handle is locked inside the closure. The fix is to publish two methods that reach in and resolve the pending state on demand.
A debounced function lives in one of two states at any moment: idle (no timer) or pending (a timer is ticking toward firing fn). Every call either creates a new pending state or resets the timer of the existing one. Three different things can take you out of pending back to idle:
cancel and flush are not new mechanisms — they're just two additional ways to leave the pending state. The timer was already going to do one of those (call fn); these methods let you trigger or skip that resolution from the outside.
If you've already written debounce, the obvious move is to slap cancel and flush onto the inner function and call it done:
function debounceIiBroken(fn, wait) {
let timeoutId = null;
function debounced(...args) {
if (timeoutId !== null) clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), wait);
}
debounced.cancel = function () {
clearTimeout(timeoutId);
};
debounced.flush = function () {
fn(); // ???
};
return debounced;
}
This is wrong in three ways. First, flush calls fn() with no args — but flush is supposed to run the pending call with the args it was scheduled with. We never saved them. Second, flush fires even when nothing is pending. Click "Save" twice in a row with no edits between — you'd call fn twice with empty args. Third, cancel doesn't clear timeoutId. After cancel, timeoutId still holds a stale handle, so a later flush will look "pending" when it isn't.
The shape of the fix: pull the args out of the per-call closure and store them in the outer scope alongside timeoutId. Then both cancel and flush can read those args, and both can guard against "nothing pending" by checking timeoutId === null.
function debounceIi(fn, wait) {
// Three pieces of state, all in the outer closure so cancel/flush
// can read and reset them.
let timeoutId = null;
let lastArgs = null;
let lastThis = null;
// Single source of truth for "actually run fn now". Both the timer
// callback and flush() call this — so the cleanup logic only lives
// in one place.
function invoke() {
const args = lastArgs;
const ctx = lastThis;
// Clear BEFORE calling fn. If fn synchronously calls the debounced
// function again, that nested call should see a clean idle state.
timeoutId = null;
lastArgs = null;
lastThis = null;
fn.apply(ctx, args);
}
function debounced(...args) {
// Capture per-call args + `this` into the outer scope so flush
// can find them later. Overwrites any prior pending call's args —
// that's correct: only the LATEST args should fire.
lastArgs = args;
lastThis = this;
if (timeoutId !== null) clearTimeout(timeoutId);
timeoutId = setTimeout(invoke, wait);
}
debounced.cancel = function () {
// Guard: cancel with nothing pending is a documented no-op.
if (timeoutId === null) return;
clearTimeout(timeoutId);
// Reset all three pieces. A later flush() must see "nothing pending"
// because timeoutId is null — but we clear lastArgs too for hygiene.
timeoutId = null;
lastArgs = null;
lastThis = null;
};
debounced.flush = function () {
// Same guard: flush with nothing pending must NOT call fn.
if (timeoutId === null) return;
// Cancel the timer first so it doesn't ALSO fire later. Then run
// invoke, which uses the saved args + this and resets state.
clearTimeout(timeoutId);
invoke();
};
return debounced;
}
module.exports = { debounceIi };
Three shifts from the naive attempt. First, lastArgs and lastThis are hoisted into the outer closure alongside timeoutId, so flush can read them. Second, the actual firing logic lives in one private invoke function that the timer and flush both call — duplicating that cleanup in two places is how state bugs creep in. Third, timeoutId === null is the single source of truth for "anything pending?" — every public method checks it before doing work.
Let's run a real scenario: an auto-save with wait = 1000, and the user clicks a manual "Save now" button halfway through.
Step by step, with wait = 1000:
'hello'. debounced('hello') runs. timeoutId is null, so the clearTimeout is skipped. We set lastArgs = ['hello'], lastThis = obj. Schedule a timer for t=1000. Stash its handle in timeoutId.'hello!'. debounced('hello!') runs. timeoutId is set, so we cancel the t=1000 timer. Overwrite lastArgs = ['hello!']. Schedule a new timer for t=1200.d.flush(). The flush guard checks timeoutId !== null — true. We clearTimeout(timeoutId) to keep the t=1200 timer from also firing later. Then we call invoke(): it copies lastArgs into a local, resets timeoutId, lastArgs, lastThis to nulls, and finally runs fn.apply(obj, ['hello!']). fn fires with the most recent args, at t=500, not t=1200.If at t=500 the user had instead navigated away and we called d.cancel(): same guard passes, clearTimeout runs, all three state vars reset to null. fn is never called. Calling d.flush() after that is a no-op (because timeoutId === null again).
Complexity. Each call, each cancel, each flush is O(1) — one clear, one schedule, one assign at most. Space stays O(1): three slots in the closure, regardless of how many calls have happened.
fn directly in flush without clearing the timer. If flush runs fn but forgets clearTimeout(timeoutId), the original timer will also fire at its scheduled time — and now fn runs twice with the same args. Always cancel the timer before invoking.invoke. If the timer fires and you leave timeoutId set to the (now-defunct) handle, a later flush will see "pending" and re-invoke fn. Worse, lastArgs lingers and a later flush after a fresh cycle could fire the wrong args. Reset all three vars whenever fn runs.setTimeout(() => fn(...args), wait) and let the arrow function close over args. That works for the timer, but flush has no access to that closure — it lives outside the per-call function. Hoist args into the outer scope so both paths can read them.cancel not clearing lastArgs. Strictly speaking, only the timeoutId === null check matters for correctness — but if lastArgs keeps the old reference, you're holding the user's data alive for no reason. Clear it for hygiene.debounced. If you use const debounced = (...args) => { ... }, this inside is whatever the outer this is at definition time — not what the caller passed. Use a regular function so obj.cb = debouncedIi(...); obj.cb() forwards obj as this.flush() returning the result. Lodash's flush returns whatever fn would have returned, which lets you write const value = d.flush() after a sync flush. Add a return fn.apply(ctx, args) and bubble that return up through invoke and flush.pending() introspection. A boolean read-only method that returns timeoutId !== null. Useful for UI: "show a spinner while there's a pending save" or "disable the Save button when there's nothing to save."{ leading: true, trailing: true } options bag that controls whether the first call fires immediately, the last fires after silence, or both. The interactions with cancel/flush get subtle — cancel should still drop the trailing call even after the leading one fired. That's the version lodash ships.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
You've built a basic debounce that defers a call until the user stops triggering events for wait milliseconds. In real applications you almost always need two more controls on top of that: a way to throw the pending call away, and a way to run it right now instead of waiting. Your job is to extend debounce so the returned function carries cancel() and flush() methods that do exactly that.
function debounceIi(
fn: (...args: any[]) => any,
wait: number
): {
(...args: any[]): void;
cancel(): void; // abort any pending invocation; fn is NOT called
flush(): void; // invoke any pending invocation immediately; no-op if nothing is pending
};
The returned value is a function. cancel and flush are properties attached to that function.
// 1) cancel() throws away the pending call
const d = debounceIi(console.log, 100);
d('hello');
d.cancel();
// 100ms later: nothing prints. The pending call was discarded.
// 2) flush() runs the pending call right now with the latest args
const d = debounceIi(console.log, 1000);
d('a');
d('b');
d.flush(); // prints 'b' immediately — does NOT wait the remaining ~1000ms
d.flush(); // no-op: nothing is pending anymore
d(...) until the timer fires (or cancel/flush resolves it).fn must NOT be invoked. Same after a prior flush or cancel.this — if the user attaches the debounced function as an object method (obj.save = debouncedIi(...)) and calls obj.save(), the eventual fn invocation should see obj as its this.You'll take a working debounce and bolt on two control surfaces: a cancel() that throws away a pending call, and a flush() that runs it right now.
You've already built debounce — the thing that says "wait until the user stops typing for wait ms, then fire once with the latest args." That's enough for a search box, but real apps need to steer the pending call from the outside:
You can't do either with plain debounce because the timer handle is locked inside the closure. The fix is to publish two methods that reach in and resolve the pending state on demand.
A debounced function lives in one of two states at any moment: idle (no timer) or pending (a timer is ticking toward firing fn). Every call either creates a new pending state or resets the timer of the existing one. Three different things can take you out of pending back to idle:
cancel and flush are not new mechanisms — they're just two additional ways to leave the pending state. The timer was already going to do one of those (call fn); these methods let you trigger or skip that resolution from the outside.
If you've already written debounce, the obvious move is to slap cancel and flush onto the inner function and call it done:
function debounceIiBroken(fn, wait) {
let timeoutId = null;
function debounced(...args) {
if (timeoutId !== null) clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), wait);
}
debounced.cancel = function () {
clearTimeout(timeoutId);
};
debounced.flush = function () {
fn(); // ???
};
return debounced;
}
This is wrong in three ways. First, flush calls fn() with no args — but flush is supposed to run the pending call with the args it was scheduled with. We never saved them. Second, flush fires even when nothing is pending. Click "Save" twice in a row with no edits between — you'd call fn twice with empty args. Third, cancel doesn't clear timeoutId. After cancel, timeoutId still holds a stale handle, so a later flush will look "pending" when it isn't.
The shape of the fix: pull the args out of the per-call closure and store them in the outer scope alongside timeoutId. Then both cancel and flush can read those args, and both can guard against "nothing pending" by checking timeoutId === null.
function debounceIi(fn, wait) {
// Three pieces of state, all in the outer closure so cancel/flush
// can read and reset them.
let timeoutId = null;
let lastArgs = null;
let lastThis = null;
// Single source of truth for "actually run fn now". Both the timer
// callback and flush() call this — so the cleanup logic only lives
// in one place.
function invoke() {
const args = lastArgs;
const ctx = lastThis;
// Clear BEFORE calling fn. If fn synchronously calls the debounced
// function again, that nested call should see a clean idle state.
timeoutId = null;
lastArgs = null;
lastThis = null;
fn.apply(ctx, args);
}
function debounced(...args) {
// Capture per-call args + `this` into the outer scope so flush
// can find them later. Overwrites any prior pending call's args —
// that's correct: only the LATEST args should fire.
lastArgs = args;
lastThis = this;
if (timeoutId !== null) clearTimeout(timeoutId);
timeoutId = setTimeout(invoke, wait);
}
debounced.cancel = function () {
// Guard: cancel with nothing pending is a documented no-op.
if (timeoutId === null) return;
clearTimeout(timeoutId);
// Reset all three pieces. A later flush() must see "nothing pending"
// because timeoutId is null — but we clear lastArgs too for hygiene.
timeoutId = null;
lastArgs = null;
lastThis = null;
};
debounced.flush = function () {
// Same guard: flush with nothing pending must NOT call fn.
if (timeoutId === null) return;
// Cancel the timer first so it doesn't ALSO fire later. Then run
// invoke, which uses the saved args + this and resets state.
clearTimeout(timeoutId);
invoke();
};
return debounced;
}
module.exports = { debounceIi };
Three shifts from the naive attempt. First, lastArgs and lastThis are hoisted into the outer closure alongside timeoutId, so flush can read them. Second, the actual firing logic lives in one private invoke function that the timer and flush both call — duplicating that cleanup in two places is how state bugs creep in. Third, timeoutId === null is the single source of truth for "anything pending?" — every public method checks it before doing work.
Let's run a real scenario: an auto-save with wait = 1000, and the user clicks a manual "Save now" button halfway through.
Step by step, with wait = 1000:
'hello'. debounced('hello') runs. timeoutId is null, so the clearTimeout is skipped. We set lastArgs = ['hello'], lastThis = obj. Schedule a timer for t=1000. Stash its handle in timeoutId.'hello!'. debounced('hello!') runs. timeoutId is set, so we cancel the t=1000 timer. Overwrite lastArgs = ['hello!']. Schedule a new timer for t=1200.d.flush(). The flush guard checks timeoutId !== null — true. We clearTimeout(timeoutId) to keep the t=1200 timer from also firing later. Then we call invoke(): it copies lastArgs into a local, resets timeoutId, lastArgs, lastThis to nulls, and finally runs fn.apply(obj, ['hello!']). fn fires with the most recent args, at t=500, not t=1200.If at t=500 the user had instead navigated away and we called d.cancel(): same guard passes, clearTimeout runs, all three state vars reset to null. fn is never called. Calling d.flush() after that is a no-op (because timeoutId === null again).
Complexity. Each call, each cancel, each flush is O(1) — one clear, one schedule, one assign at most. Space stays O(1): three slots in the closure, regardless of how many calls have happened.
fn directly in flush without clearing the timer. If flush runs fn but forgets clearTimeout(timeoutId), the original timer will also fire at its scheduled time — and now fn runs twice with the same args. Always cancel the timer before invoking.invoke. If the timer fires and you leave timeoutId set to the (now-defunct) handle, a later flush will see "pending" and re-invoke fn. Worse, lastArgs lingers and a later flush after a fresh cycle could fire the wrong args. Reset all three vars whenever fn runs.setTimeout(() => fn(...args), wait) and let the arrow function close over args. That works for the timer, but flush has no access to that closure — it lives outside the per-call function. Hoist args into the outer scope so both paths can read them.cancel not clearing lastArgs. Strictly speaking, only the timeoutId === null check matters for correctness — but if lastArgs keeps the old reference, you're holding the user's data alive for no reason. Clear it for hygiene.debounced. If you use const debounced = (...args) => { ... }, this inside is whatever the outer this is at definition time — not what the caller passed. Use a regular function so obj.cb = debouncedIi(...); obj.cb() forwards obj as this.flush() returning the result. Lodash's flush returns whatever fn would have returned, which lets you write const value = d.flush() after a sync flush. Add a return fn.apply(ctx, args) and bubble that return up through invoke and flush.pending() introspection. A boolean read-only method that returns timeoutId !== null. Useful for UI: "show a spinner while there's a pending save" or "disable the Save button when there's nothing to save."{ leading: true, trailing: true } options bag that controls whether the first call fires immediately, the last fires after silence, or both. The interactions with cancel/flush get subtle — cancel should still drop the trailing call even after the leading one fired. That's the version lodash ships.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.