Debounce & Throttle
Some events fire far more often than you actually want to react to them. A user typing in a search box can trigger dozens of input events per second, and scrolling fires scroll continuously. If each event runs an expensive handler — a network request, a layout recalculation, a re-render — your UI stutters and your server gets hammered. Debounce and throttle are two closure-based techniques that tame this flood by deciding when a function is allowed to run.
The core difference
Both wrap a function and limit how often it executes, but they answer different questions:
- Debounce waits for activity to stop. It postpones the call until a quiet period of
delaymilliseconds has passed with no new events. Great for “do this once the user is done.” - Throttle enforces a steady rate. It runs the function at most once every
intervalmilliseconds while events keep coming. Great for “do this regularly, but not too often.”
A timeline makes it concrete. Imagine events firing rapidly, then a pause:
events: X X X X X X X
debounce: D D (fires after a quiet gap)
throttle: T T T T T (fires on a fixed cadence)
Debounce collapses a burst into a single trailing call. Throttle samples the burst at regular intervals.
Implementing debounce
Debounce relies on a closure that remembers a timer ID between calls. Each invocation clears the pending timer and schedules a fresh one, so the function only runs once the calls stop.
function debounce(fn, delay = 300) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const log = debounce((value) => console.log("Searching:", value), 500);
log("h");
log("he");
log("hel");
log("hello"); // only this one runs, 500ms after the last call
Output:
Searching: hello
We use a regular function (not an arrow) for the returned wrapper so that this and arguments come from the call site, then forward them with fn.apply(this, args). This keeps debounce usable as an event handler or object method.
Tip: For search-as-you-type, a 300-500ms debounce is the sweet spot — short enough to feel instant, long enough to skip intermediate keystrokes.
Leading-edge variant
Sometimes you want the first call to fire immediately and then suppress the rest. Add an immediate flag:
function debounce(fn, delay = 300, immediate = false) {
let timer;
return function (...args) {
const callNow = immediate && !timer;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!immediate) fn.apply(this, args);
}, delay);
if (callNow) fn.apply(this, args);
};
}
Implementing throttle
Throttle tracks the timestamp of the last run and ignores calls that arrive too soon. Here is a timestamp-based version:
function throttle(fn, interval = 200) {
let lastRun = 0;
return function (...args) {
const now = Date.now();
if (now - lastRun >= interval) {
lastRun = now;
fn.apply(this, args);
}
};
}
const onScroll = throttle(() => console.log("scroll at", Date.now()), 1000);
window.addEventListener("scroll", onScroll);
This fires on the leading edge and then at most once per interval. A common refinement adds a trailing call so the final event in a burst is never lost:
function throttle(fn, interval = 200) {
let lastRun = 0;
let timer = null;
return function (...args) {
const now = Date.now();
const remaining = interval - (now - lastRun);
if (remaining <= 0) {
clearTimeout(timer);
timer = null;
lastRun = now;
fn.apply(this, args);
} else if (!timer) {
timer = setTimeout(() => {
lastRun = Date.now();
timer = null;
fn.apply(this, args);
}, remaining);
}
};
}
When to use which
| Scenario | Technique | Why |
|---|---|---|
| Search / autocomplete input | Debounce | Wait until typing pauses before querying |
| Saving a draft as the user edits | Debounce | One save after edits settle |
| Scroll position / infinite scroll | Throttle | Steady sampling, not after scrolling stops |
Window resize re-layout | Throttle (or debounce for final layout) | Limit costly reflows |
| Button to prevent double-submit | Debounce (leading) | Run once, ignore rapid repeats |
| Mouse-move drag tracking | Throttle | Smooth, bounded update rate |
Warning: Throttle does not cancel — it spaces calls out. If you need the action to happen only after the user truly stops (e.g. a final API write), debounce is the correct tool, not throttle.
A self-contained demo you can edit live:
<input id="q" placeholder="Type to search..." />
<p id="out">waiting…</p>
<script>
const debounce = (fn, d = 400) => {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), d); };
};
const out = document.getElementById("out");
document.getElementById("q").addEventListener(
"input",
debounce((e) => { out.textContent = "Searching: " + e.target.value; }, 400)
);
</script>
Best practices
- Choose debounce for “after the user stops” and throttle for “at a steady cadence” — picking the wrong one is the most common mistake.
- Use a regular
functionin the wrapper and forwardthis/argswithapplyso the helper works as a method or DOM handler. - Expose a
cancel()method (clearing the timer) when you need to abort pending calls on component unmount to avoid stale updates and leaks. - Keep delays tunable; 300-500ms suits typing, 100-200ms suits scroll and mouse-move.
- Always remove throttled/debounced listeners in cleanup code — the closed-over timer can otherwise keep references alive.
- Reach for a vetted utility (lodash
debounce/throttle) in production when you need edge options likemaxWait, but understand the mechanics first.