Debounce a function
The eight-line debounce that waits for typing to stop before firing — with the this-and-arguments handling naive versions drop.
Last updated
function debounce(fn, delayMs) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delayMs);
};
}
// input.addEventListener("input", debounce(runSearch, 300));How it works
Each call clears the previous timer and starts a new one, so the wrapped function runs only after the calls STOP for delayMs — the behaviour you want for search boxes and resize handlers, where only the final state matters.
The returned function is a regular function (not an arrow) and uses fn.apply(this, args) on purpose: it forwards both the caller's this and the event arguments, so it works as a method and as an event listener without wrapping.
Edge cases to know
- →Debounce delays the FIRST call too. If you need immediate-then-quiet behaviour (a button guard), you want a leading-edge variant or a throttle instead.
- →The pending timer survives component unmount — in React, clear it in the effect cleanup or the callback can fire against a dead component.
- →One debounced function holds one timer: create it once (not inside a render/loop) or every call gets a fresh timer and nothing is debounced.
Part by part
- let timer;
- Held in the closure rather than on the function, so each debounced wrapper keeps its own timer and two instances cannot interfere.
- clearTimeout(timer)
- Cancels the pending call before scheduling a new one. This single line is the entire mechanism — every call resets the wait.
- fn.apply(this, args)
- Preserves both the calling context and the arguments. An arrow function or a bare fn(args) loses one or the other, which breaks method calls and event handlers.
- delayMs
- The quiet period, not the interval. The function runs once calls have stopped for this long, which is what suits a search box.
Common mistakes
input.addEventListener("input", () => debounce(runSearch, 300)())Creates a new debounced function on every event, so each has its own timer and nothing is ever cancelled. The handler fires every time, which looks exactly like the debounce not working.
timer = setTimeout(() => fn(...args), delayMs)Drops the calling context, so a debounced method loses its this. It works for standalone functions and breaks the moment it is used on an object.
Debouncing a scroll handler.Debounce waits for the activity to stop, so nothing runs during the scroll. Throttle is the tool there — it runs at a steady rate while the events continue.
Questions
What is the difference from throttle?
Debounce runs once after the calls stop; throttle runs at most once per interval while they continue. Search suggestions want debounce; scroll position and progress indicators want throttle.
Why must the debounced function be created once?
Because the timer lives in its closure. Creating it inside the handler gives every event a fresh closure with a fresh timer, so nothing is ever cancelled and the debounce silently does nothing.
What delay should I use?
Around 250 to 400 milliseconds for typing, which is long enough to skip intermediate keystrokes and short enough to feel responsive. Below about 150 it stops saving meaningful work.
How do I cancel a pending call?
Expose the timer by attaching a cancel method to the returned function. It matters in components that unmount, where a pending call can otherwise run against something that no longer exists.