You've used a search box that fires a request on every keystroke and floods the network, or a window-resize handler that runs hundreds of times during a single drag. Debouncing is the standard fix: wait until the user pauses, then act once on the final state.
Implement a debounce function that, given a callback fn and a delay wait (ms), returns a new function. The returned function delays invoking fn until wait milliseconds have elapsed since the last time it was called. If it's called again within wait, the previous timer is cleared.
function debounce(fn, wait) {
// returns a new function that, when called repeatedly,
// only invokes `fn` once after `wait` ms of inactivity
}
const log = debounce((msg) => console.log(msg), 100);
log('a');
log('b');
log('c');
// After 100ms of silence: logs 'c' only
setTimeout and clearTimeout — no external libraries.obj.onInput()), this inside fn must still refer to that object — not to undefined or the global object.cancel / flush methods for v1.You'll build a function that schedules work for later, then cancels that work if it's called again before the timer fires.
Imagine a search box. The user types j, ja, jav, java, javas, javasc, javascr, javascri, javascrip, javascript. You don't want to send ten requests to the server — you only care about what they ended up typing. Debounce lets you say: "wait until they stop typing for 500ms, then fire once with the final value."
Every call says two things to the timer: "cancel whatever was scheduled" and "schedule something new." As long as calls keep arriving, the timer keeps getting reset. It only actually fires when calls stop for at least wait milliseconds.
A reasonable first try is just setTimeout with no cancellation:
function debounceBroken(fn, wait) {
return function (...args) {
setTimeout(() => fn(...args), wait);
};
}
This looks right — it delays fn by wait ms. But it never cancels anything. If the user calls it three times in a row, you get three pending timers, and fn fires three times. That's not debouncing — that's just delaying.
You need to remember the pending timer between calls so you can cancel it. That's what a closure is for: a variable in the outer scope that the inner function reads and writes on every call.
function debounce(fn, wait) {
let timeoutId = null;
return function debounced(...args) {
if (timeoutId !== null) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
fn.apply(this, args);
}, wait);
};
}
module.exports = { debounce };
The whole trick is let timeoutId = null sitting in the outer scope. Every call to the returned function sees the same timeoutId, so each call can cancel what the previous one scheduled.
Say wait = 500 and the user types a, b, c 100ms apart:
a. timeoutId is null, so clearTimeout(null) is a no-op. Schedule a timer for t=500. Save its id in timeoutId.b. timeoutId is set, so clearTimeout(timeoutId) cancels the t=500 timer. Schedule a new timer for t=600. Save its id.c. Cancel the t=600 timer. Schedule a timer for t=700.fn is called with c (the most recent args). timeoutId is set back to null.Each call does O(1) work — one clearTimeout, one setTimeout, one assignment. Space is O(1) too: the closure holds a single timer handle and the most recent args reference, regardless of how many times the debounced function has been called.
clearTimeout — every call schedules its own timer and fn fires once per call. That's the broken first attempt above.function debounced(...args) parameter does this; a closure-level let lastArgs would get overwritten and the wrong call's args could leak.=> arrow for the returned function — arrow functions bind this to the surrounding scope at definition time. Use function so obj.method = debounce(...) forwards this to fn correctly via fn.apply(this, args).Date.now() instead of setTimeout — works but wastes CPU and is harder to test. Stick with the timer API.Real-world debounce libraries (lodash, underscore) add three extensions you can build on top of this:
leading: true — fire on the first call as well as the last, so users see immediate feedback..cancel() — discard any pending call. Useful when a component unmounts..flush() — fire the pending call immediately instead of waiting out the timer.Each is ~5 lines on top of what you wrote here.
Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
You've used a search box that fires a request on every keystroke and floods the network, or a window-resize handler that runs hundreds of times during a single drag. Debouncing is the standard fix: wait until the user pauses, then act once on the final state.
Implement a debounce function that, given a callback fn and a delay wait (ms), returns a new function. The returned function delays invoking fn until wait milliseconds have elapsed since the last time it was called. If it's called again within wait, the previous timer is cleared.
function debounce(fn, wait) {
// returns a new function that, when called repeatedly,
// only invokes `fn` once after `wait` ms of inactivity
}
const log = debounce((msg) => console.log(msg), 100);
log('a');
log('b');
log('c');
// After 100ms of silence: logs 'c' only
setTimeout and clearTimeout — no external libraries.obj.onInput()), this inside fn must still refer to that object — not to undefined or the global object.cancel / flush methods for v1.You'll build a function that schedules work for later, then cancels that work if it's called again before the timer fires.
Imagine a search box. The user types j, ja, jav, java, javas, javasc, javascr, javascri, javascrip, javascript. You don't want to send ten requests to the server — you only care about what they ended up typing. Debounce lets you say: "wait until they stop typing for 500ms, then fire once with the final value."
Every call says two things to the timer: "cancel whatever was scheduled" and "schedule something new." As long as calls keep arriving, the timer keeps getting reset. It only actually fires when calls stop for at least wait milliseconds.
A reasonable first try is just setTimeout with no cancellation:
function debounceBroken(fn, wait) {
return function (...args) {
setTimeout(() => fn(...args), wait);
};
}
This looks right — it delays fn by wait ms. But it never cancels anything. If the user calls it three times in a row, you get three pending timers, and fn fires three times. That's not debouncing — that's just delaying.
You need to remember the pending timer between calls so you can cancel it. That's what a closure is for: a variable in the outer scope that the inner function reads and writes on every call.
function debounce(fn, wait) {
let timeoutId = null;
return function debounced(...args) {
if (timeoutId !== null) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
fn.apply(this, args);
}, wait);
};
}
module.exports = { debounce };
The whole trick is let timeoutId = null sitting in the outer scope. Every call to the returned function sees the same timeoutId, so each call can cancel what the previous one scheduled.
Say wait = 500 and the user types a, b, c 100ms apart:
a. timeoutId is null, so clearTimeout(null) is a no-op. Schedule a timer for t=500. Save its id in timeoutId.b. timeoutId is set, so clearTimeout(timeoutId) cancels the t=500 timer. Schedule a new timer for t=600. Save its id.c. Cancel the t=600 timer. Schedule a timer for t=700.fn is called with c (the most recent args). timeoutId is set back to null.Each call does O(1) work — one clearTimeout, one setTimeout, one assignment. Space is O(1) too: the closure holds a single timer handle and the most recent args reference, regardless of how many times the debounced function has been called.
clearTimeout — every call schedules its own timer and fn fires once per call. That's the broken first attempt above.function debounced(...args) parameter does this; a closure-level let lastArgs would get overwritten and the wrong call's args could leak.=> arrow for the returned function — arrow functions bind this to the surrounding scope at definition time. Use function so obj.method = debounce(...) forwards this to fn correctly via fn.apply(this, args).Date.now() instead of setTimeout — works but wastes CPU and is harder to test. Stick with the timer API.Real-world debounce libraries (lodash, underscore) add three extensions you can build on top of this:
leading: true — fire on the first call as well as the last, so users see immediate feedback..cancel() — discard any pending call. Useful when a component unmounts..flush() — fire the pending call immediately instead of waiting out the timer.Each is ~5 lines on top of what you wrote here.
Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.