Community Article

Debounce, Throttle, and the Difference People Miss

Debounce settles, throttle paces. The visual difference, the canonical implementations of both (with leading-edge and trailing-call variants), and the three edge cases that bite hand-rolled wrappers.

Debounce, Throttle, and the Difference People Miss

Debounce settles, throttle paces. The visual difference, the canonical implementations of both (with leading-edge and trailing-call variants), and the three edge cases that bite hand-rolled wrappers.

throttling
higher-order-functions
frontend
interview-prep
fundamentals
nathanmurphy

By @nathanmurphy

December 3, 2025

·

Updated May 18, 2026

1,010 views

22

4.1 (9)

A coworker asked me last week, "should this be debounced or throttled?" The question was about a search-as-you-type input. I asked back, "do you want a request after the user stops typing, or do you want to cap the request rate while they keep typing?" The answer was the first one, which means debounce. The whole topic, in my experience, fits in that single question, and the rest of this piece is the supporting code for both halves of the answer.

The visual difference is the cleanest way to remember which is which. Debounce waits for silence: the function fires only after the user has stopped triggering it for some quiet period. Throttle caps the rate: the function fires at most once per interval, no matter how often it is triggered. The two solve different problems, and the easiest way to pick the wrong one is to confuse them.

The visual difference

Trigger pattern:           T T T T   T T T T T T   T   T T

Debounce (200ms wait):     . . . .D  . . . . . .D  . . . D
                                    ^                  ^   ^
                                fires after 200ms of silence

Throttle (100ms cap):      F . . .   F . . . F .   F   F .
                           ^         ^         ^   ^   ^
                           fires at most once per 100ms

T is a trigger event (a keypress, a scroll, a resize). D is when the debounced function fires. F is when the throttled function fires. The patterns are visibly different. Debounce produces fewer, "settled" calls; throttle produces a steady stream capped at the rate.

If you want the function to fire after the user has finished doing something, that is debounce. If you want the function to fire periodically while something keeps happening, that is throttle.

When I reach for each

The cases are pretty stable across codebases.

PatternReach forWhy
Search-as-you-type, send request when typing pausesdebounceAvoid a request per keystroke
Auto-save a form after the user stops editingdebounceSave once when they pause
Resize handler that updates layoutthrottleCap the rate but stay responsive while resizing
Scroll handler that updates a sticky headerthrottleSame: keep up but do not run every frame
Mouse-move tracker for analyticsthrottleCap the sample rate
Validating a single field after the user stops typingdebounceValidate the settled value, not the in-progress one
Rate-limiting an outgoing API callthrottleHard cap on call rate

The mnemonic that works for me: "debounce settles, throttle paces". If the goal is "fire when things settle down", debounce. If the goal is "fire at a paced rate while things are happening", throttle.

A clean debounce implementation

function debounce(fn, wait) {
    let timer = null;
    return function (...args) {
        if (timer) clearTimeout(timer);
        timer = setTimeout(() => {
            timer = null;
            fn.apply(this, args);
        }, wait);
    };
}

The mechanic: every call clears the previous pending timer and sets a new one. The function fn runs only when a timer expires without being cleared, which means there has been at least wait ms of silence since the last call. The closure variable timer holds the active timeout ID; the function uses it as both a flag (is there a pending call?) and the handle to cancel.

The arguments and this are forwarded with apply so the wrapper is transparent to the caller. If you wrap a method, calling the wrapper as a method of the object preserves the receiver.

Leading-edge debounce: the "fire immediately, then wait" variant

The standard debounce fires on the trailing edge: after the silence period, the call goes through. Sometimes you want the opposite: fire on the leading edge (immediately on the first call), then suppress further calls until the silence period has passed.

function debounceLeading(fn, wait) {
    let timer = null;
    return function (...args) {
        if (timer === null) fn.apply(this, args);
        if (timer) clearTimeout(timer);
        timer = setTimeout(() => {
            timer = null;
        }, wait);
    };
}

Useful for "submit on click but ignore double-clicks for 500ms" or "open modal on first click, ignore subsequent". Lodash and other utility libraries support a leading: true, trailing: false config; the implementation above is the inline equivalent.

A clean throttle implementation

function throttle(fn, interval) {
    let lastCall = 0;
    let pendingTimer = null;
    return function (...args) {
        const now = Date.now();
        const elapsed = now - lastCall;
        if (elapsed >= interval) {
            lastCall = now;
            fn.apply(this, args);
        } else if (!pendingTimer) {
            // schedule a trailing call so the last trigger does not get dropped
            pendingTimer = setTimeout(() => {
                lastCall = Date.now();
                pendingTimer = null;
                fn.apply(this, args);
            }, interval - elapsed);
        }
    };
}

The first call fires immediately and sets lastCall to now. Subsequent calls within the interval are dropped, except for the last one before the interval expires, which is scheduled for the trailing edge. Without the trailing-call logic, a burst of calls followed by silence would lose the final values, which is usually the wrong behaviour for things like resize.

The trailing call is the most common bug in hand-rolled throttles. A naive throttle that only checks "has the interval elapsed" drops the last value silently, which produces "the layout did not update after the user stopped resizing" complaints.

Edge cases I keep getting wrong

Three traps that have bitten me on real projects.

Cancellation. A debounced function may be pending when the component unmounts (in React) or when the page navigates away. The pending callback fires later and may reference state that no longer exists. The fix is to expose a cancel method on the wrapper:

function debounce(fn, wait) {
    let timer = null;
    const wrapped = function (...args) {
        if (timer) clearTimeout(timer);
        timer = setTimeout(() => {
            timer = null;
            fn.apply(this, args);
        }, wait);
    };
    wrapped.cancel = () => {
        if (timer) clearTimeout(timer);
        timer = null;
    };
    return wrapped;
}

// in a React useEffect cleanup:
useEffect(() => () => debouncedFn.cancel(), [debouncedFn]);

Stale closure. If you debounce inside a React component without useCallback and the dependency array, every render produces a new wrapper, so the timer is reset on every render. The fix is useMemo or useCallback to memoise the wrapper, or a useRef to hold a single instance across renders.

Argument freshness. The wrapped function fires with the arguments of the most recent call, not the first. For most cases this is what you want; for cases where you need to coalesce all the calls (sum a counter, list every value), the wrapper has to accumulate state internally rather than just forward the latest call.

A one-line answer to "which one do I want?"

If you have to pick between debounce and throttle in less than ten seconds, the heuristic is: do you want the function to run after activity stops (debounce) or while activity continues (throttle)? Reread the visual diagram from the top if you are unsure; the patterns are visibly distinct.

For most application code, debounce is the right answer for input-driven UI (search, validation, save) and throttle is the right answer for high-frequency event-driven UI (scroll, resize, mousemove, animation). The two are not interchangeable, and picking the wrong one produces complaints that are easy to identify in retrospect: "we are sending too many requests" usually means missing debounce, "the UI feels frozen during resize" usually means missing throttle.

The implementations I showed above are about twenty lines each. They handle the common cases without dragging in a utility library. If you find yourself reaching for lodash.debounce for a single-use throttle, consider whether the inline version is enough; for most cases, it is, and the dependency footprint shrinks accordingly. The library version becomes worth the import when you want every option (leading, trailing, maxWait, flush, pending, cancellation), and that is a real shape worth knowing exists when the inline version cannot stretch any further.

Back to Articles