Frontend
Debouncing vs. Throttling
Two small functions that solve the same problem — a high-frequency event firing too often — in opposite ways. Here's how to write both from scratch.
Type into a search box, scroll a page, resize a window, or drag a mouse across the screen, and the browser will happily fire dozens of events per second. If every one of those events triggers an API call, a re-render, or a layout calculation, you end up doing far more work than the user actually needs — and the UI gets janky in the process. Debouncing and throttling are the two standard fixes, and it's one of the most common "write it live" interview questions precisely because it looks trivial and isn't — the closure has to hold onto state between calls, and it's easy to get that state management subtly wrong.
Both techniques wrap a function and return a new function that decides, on each invocation, whether the original should actually run right now. The difference is entirely in when that decision comes back "yes."
Debounce: wait for silence, then fire once
A debounced function waits until the event stream goes quiet for a fixed window of time, and only then does it run — and it runs exactly once, using the arguments from the last call it received. If another call arrives before that window elapses, the clock resets. The canonical example is search-as-you-type: you don't want to hit the API on every keystroke, only once the user has actually paused.
function debounce(fn, delay) {
let timerId; // captured in the closure, survives between calls
return function (...args) {
// Every new call cancels whatever was scheduled before
clearTimeout(timerId);
timerId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// Usage: only fires once the user stops typing for 300ms
searchInput.addEventListener(
"input",
debounce((e) => fetchResults(e.target.value), 300)
);The closure here is doing all the work. timerId is declared outside the returned function, so it isn't re-initialized on every keystroke — it persists across calls, exactly like the counter in a closure-based counter example. Each time the debounced function runs, it clears whatever timeout was pending and schedules a fresh one. As long as calls keep arriving faster than delay, no timeout ever survives long enough to fire. The moment the calls stop, the last scheduled timeout finally completes and fn runs — once, with the most recent arguments.
Leading vs. trailing
The version above is a "trailing edge" debounce — it fires after the pause. Some implementations also support a "leading edge" option that fires immediately on the first call and then ignores subsequent calls until things go quiet, useful for things like preventing a double-click on a submit button.
Throttle: fire at a steady rate, no matter what
A throttled function guarantees the opposite property: it runs at most once every fixed interval, regardless of how many times the event fires in between — it never goes fully silent as long as events keep arriving. This is what you want for scroll position, mouse-move tracking, or a "save progress" indicator, where you need regular updates during the activity, not just a single update after it ends.
function throttle(fn, limit) {
let lastCall = 0; // captured in the closure, survives between calls
return function (...args) {
const now = Date.now();
if (now - lastCall >= limit) {
lastCall = now;
fn.apply(this, args);
}
};
}
// Usage: updates at most once every 200ms while scrolling
window.addEventListener(
"scroll",
throttle(() => updateScrollProgress(), 200)
);Again, the closure is what makes this work: lastCall lives outside the returned function, so it remembers the timestamp of the last time fn actually ran. Every new call checks how much time has passed since then. If enough time has elapsed, it updates lastCall and runs fn; if not, the call is simply dropped. The effect is a steady drip of executions no faster than one per limit milliseconds, however frantically the underlying event fires.
Don't lose the final event
The throttle above can drop the very last call if it lands inside the "too soon" window — the scrollbar might stop at a position that never got reported. Production implementations (lodash's throttle included) usually schedule a trailing call with setTimeout to catch this, combining throttling's steady cadence with a bit of debounce-style cleanup at the end.
Picking the right one
The decision rule comes straight from what each function guarantees. Reach for debounce when you only care about the final state once activity stops — search input, form validation, auto-save on a text editor, resize handlers that recalculate an expensive layout. Reach for throttle when you need regular feedback while activity is still happening — infinite scroll triggers, drag-to-resize previews, mouse-move-driven animations, or rate-limiting a "like" button. Neither is universally "better"; they answer different questions about the same firehose of events.
Key takeaways
- Both debounce and throttle exist because high-frequency events (typing, scrolling, resizing) fire more often than the app needs to react.
- Debounce fires once, after the event stream goes quiet for N ms — perfect for search-as-you-type.
- Throttle fires at most once every N ms while events keep arriving — perfect for scroll or mouse tracking.
- Both rely on a closure holding state (a timer ID or a last-call timestamp) between invocations of the returned function.
- Debounce when you only need the final result; throttle when you need steady updates during continuous activity.
