Explain the concept of debouncing and throttling
TL;DR
Debouncing and throttling are techniques used to control the rate at which a function is executed. Debouncing ensures that a function is only called after a specified delay has passed since the last time it was invoked. Throttling ensures that a function is called at most once in a specified time interval.
Debouncing delays the execution of a function until a certain amount of time has passed since it was last called. This is useful for scenarios like search input fields where you want to wait until the user has stopped typing before making an API call.
function debounce(func, delay) {let timeoutId;return function (...args) {clearTimeout(timeoutId);timeoutId = setTimeout(() => func.apply(this, args), delay);};}const debouncedHello = debounce(() => console.log('Hello world!'), 2000);debouncedHello(); // Prints 'Hello world!' after 2 seconds
Throttling ensures that a function is called at most once in a specified time interval. This is useful for scenarios like window resizing or scrolling where you want to limit the number of times a function is called.
function throttle(func, limit) {let inThrottle;return function (...args) {if (!inThrottle) {func.apply(this, args);inThrottle = true;setTimeout(() => (inThrottle = false), limit);}};}const handleResize = throttle(() => {// Update element positionsconsole.log('Window resized at', new Date().toLocaleTimeString());}, 2000);// Simulate rapid calls to handleResize every 100mslet intervalId = setInterval(() => {handleResize();}, 100);// 'Window resized' is outputted only every 2 seconds due to throttling
What is debouncing in JavaScript?
Debouncing is a technique that ensures a function only executes after a specified period of inactivity. Every time the function is called, the timer resets. Only when the calls stop for the specified delay does the function actually run.
Example use case
Imagine you have a search input field and you want to make an API call to fetch search results. Without debouncing, an API call would be made every time the user types a character, which could lead to a large number of unnecessary calls. Debouncing ensures that the API call is only made after the user has stopped typing for a specified amount of time.
Code example
function debounce(func, delay) {let timeoutId;return function (...args) {clearTimeout(timeoutId);timeoutId = setTimeout(() => func.apply(this, args), delay);};}// Usageconst handleSearch = debounce((query) => {// Make API callconsole.log('API call with query:', query);}, 300);document.getElementById('searchInput').addEventListener('input', (event) => {handleSearch(event.target.value);});
What is throttling in JavaScript?
Throttling is a technique that ensures a function runs at most once per specified time interval, no matter how often it is invoked. Calls in between are dropped (or coalesced into the next interval).
Example use case
Imagine you have a function that updates the position of elements on the screen based on the window size. Without throttling, this function could be called many times per second as the user resizes the window, leading to performance issues. Throttling ensures that the function is only called at most once in a specified time interval.
Code example
function throttle(func, limit) {let inThrottle;return function (...args) {if (!inThrottle) {func.apply(this, args);inThrottle = true;setTimeout(() => (inThrottle = false), limit);}};}// Usageconst handleResize = throttle(() => {// Update element positionsconsole.log('Window resized at', new Date().toLocaleTimeString());}, 1000);// In a real app you'd just do: window.addEventListener('resize', handleResize)// The setInterval below only exists to drive the demo in the playground.let count = 0;const intervalId = setInterval(() => {handleResize();if (++count >= 30) clearInterval(intervalId);}, 100);// 'Window resized' is logged at most once per second despite 10 calls/second
Debounce vs throttle: what's the difference?
| Debounce | Throttle | |
|---|---|---|
| When does the function fire? | After a quiet period (no calls for delay ms) | At most once per interval ms while calls keep coming |
| What happens to intermediate calls? | All but the most recent are dropped | All but the first per interval are dropped (or coalesced if trailing edge is enabled) |
| Worst-case latency | Unbounded: if calls never stop, the function never fires | Bounded: fires at least once per interval |
| Output cadence with rapid-fire input | One call after silence | Steady stream, one per interval |
| Best for | "Wait until the user is done": search-as-you-type, autosave-after-edits, validation-after-input | "Sample at a rate I control": scroll, mousemove, resize, drag |
In one line: debounce coalesces a burst of calls into one call at the end; throttle limits the burst to a steady cadence.
Leading and trailing edges
Production implementations of debounce and throttle (such as Lodash's) accept leading and trailing flags. The basic implementations above are trailing-only debounce and leading-only throttle, which is why they often surprise candidates in interviews.
| Function | leading default | trailing default | Behavior |
|---|---|---|---|
_.debounce(fn, wait) | false | true | Fires only at the end of the quiet period |
_.throttle(fn, wait) | true | true | Fires at the start AND at the end of each interval |
Here is a debounce that supports both edges plus a maxWait cap (so it cannot defer forever):
function debounce(fn,wait,{ leading = false, trailing = true, maxWait } = {},) {let timer = null;let lastCall = 0;let lastInvoke = 0;let lastArgs;let lastThis;function invoke(time) {lastInvoke = time;const args = lastArgs;const ctx = lastThis;lastArgs = lastThis = null;fn.apply(ctx, args);}return function debounced(...args) {const now = Date.now();const isFirstCall = lastCall === 0;lastCall = now;lastArgs = args;lastThis = this;if (timer) clearTimeout(timer);if (leading && isFirstCall) invoke(now);const remainingWait = maxWait? Math.min(wait, maxWait - (now - lastInvoke)): wait;timer = setTimeout(() => {timer = null;if (trailing && lastArgs) invoke(Date.now());lastCall = 0;}, remainingWait);};}
Predict the output
Debounce closure: which call wins?
What does this print?
function debounce(fn, delay) {let t;return (...args) => {clearTimeout(t);t = setTimeout(() => fn(...args), delay);};}const log = debounce((x) => console.log(x), 100);for (var i = 0; i < 3; i++) log(i);// What gets logged?
Only 2 is logged, once. The first two calls are cancelled by clearTimeout, and only the last args (when i === 2) survives. This is the defining behavior of trailing-edge debounce: the call fires with the most recent arguments after the quiet period. The var is incidental; the debounce is what drops the earlier calls.
Throttle drops the trailing call
This is one of the most common debounce/throttle interview bugs. The basic throttle from earlier looks correct, but has a subtle problem:
function throttle(fn, limit) {let inThrottle = false;return function (...args) {if (!inThrottle) {fn.apply(this, args);inThrottle = true;setTimeout(() => (inThrottle = false), limit);}};}const log = throttle((x) => console.log('fired:', x), 100);log(1); // fires: 1log(2); // dropped (inside cooldown)log(3); // dropped (inside cooldown)// 100ms later: nothing fires, even though log(3) was the latest
The fix is to queue the latest arguments and invoke them when the cooldown ends, so the user does not lose the final state of a drag, scroll, or resize. Lodash does this by default via its trailing: true option.
requestAnimationFrame throttle
For scroll and pointer handlers, time-based throttle is the wrong tool. The browser paints at the display refresh rate (typically 60Hz, or 16.7ms per frame), and setTimeout does not align with that. The handler can fire mid-paint and cause jank. Use requestAnimationFrame instead:
function rafThrottle(fn) {let queued = false;let lastArgs;return function (...args) {lastArgs = args;if (queued) return;queued = true;requestAnimationFrame(() => {fn.apply(this, lastArgs);queued = false;});};}const onScroll = rafThrottle((y) => console.log('scrollY:', y));onScroll(10);onScroll(20);onScroll(30); // only the latest fires once per frame
rafThrottle automatically scales with the user's monitor (smooth on 120Hz, fewer paints on 30Hz) and never causes wasted work between paints.
Common mistakes
- Treating debounce and throttle as interchangeable. Saying "I'd use debounce for scroll" is wrong: scroll wants throttle (or
requestAnimationFrame). Debounce on scroll only fires when the user stops scrolling. - Forgetting
thisandargsin the inner function.setTimeout(fn, delay)loses both, so the wrapper must callfn.apply(this, args)to preserve them. - No cleanup in React. Returning a debounced function from a render without
useMemooruseCallbackcreates a new debouncer on every render, which means the timer is never actually cleared. On unmount, the trailing call also still fires on a dead component (usecancel()oruseEffectcleanup). - Picking throttle when the requirement is "wait until they stop." Autosave-on-edit is a debounce problem; throttling it means saving mid-typing.
- Picking debounce when latency matters. A debounced save with no
maxWaitcan defer indefinitely if the user keeps typing. The user navigates away, and the save never fires.