StackPractices
intermediate By Mathias Paulenko

Debounce and Throttle Functions in JavaScript

Control function execution rate with debounce and throttle. Covers leading and trailing edge, cancelable timers, and real-world use cases.

Overview

Debounce and throttle keep rapid events from overwhelming your code. Debounce waits until the activity pauses before running the function. Throttle lets the function run once per interval at most, even when the event keeps firing. Both are useful for scroll, resize, input, and mousemove handlers.

I’ve seen teams ship without these and then wonder why their search page fires 30 API calls per second. The fix is usually a one-liner, but picking the wrong one (debounce where you meant throttle, or vice versa) makes things worse, not better. I made that mistake myself early on, so I’m writing this down to save you the debugging session.

When to Use

  • Debounce: search inputs, autosave, window resize. Wait until the user stops.
  • Throttle: scroll position, mouse move, repeated clicks. Run at a fixed rate.
  • You may have an event that fires many times per second and triggers expensive work.

For alternatives, see Complete Guide to Bundle Size Optimization. If you’re building infinite scroll, throttle is usually what you want for the scroll handler.

Solution

The core idea: wrap a function in another function that decides when the inner one actually runs. Debounce resets a timer on every call; throttle checks elapsed time and skips if too soon.

flowchart diagram: subgraph Debounce

Basic debounce

function debounce(fn, delay) {
    let timeoutId;

    return function (...args) {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => {
            fn.apply(this, args);
        }, delay);
    };
}

// Usage — search input
const handleSearch = debounce((query) => {
    console.log("Searching for:", query);
    fetchResults(query);
}, 300);

input.addEventListener("input", (e) => handleSearch(e.target.value));

Basic throttle

function throttle(fn, interval) {
    let lastTime = 0;

    return function (...args) {
        const now = Date.now();
        if (now - lastTime >= interval) {
            fn.apply(this, args);
            lastTime = now;
        }
    };
}

// Usage — scroll handler
const handleScroll = throttle(() => {
    console.log("Scroll position:", window.scrollY);
}, 100);

window.addEventListener("scroll", handleScroll);

Debounce with leading edge

function debounceLeading(fn, delay) {
    let timeoutId;
    let called = false;

    return function (...args) {
        if (!called) {
            fn.apply(this, args);
            called = true;
        }
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => {
            called = false;
        }, delay);
    };
}

// Fires immediately on first call, then ignores until quiet for delay ms
const handleDoubleClick = debounceLeading(() => {
    console.log("Action triggered");
}, 500);

Debounce with leading and trailing options

function debounceAdvanced(fn, delay, { leading = false, trailing = true } = {}) {
    let timeoutId;
    let lastArgs;
    let invoked = false;

    return function (...args) {
        lastArgs = args;

        const shouldInvokeLeading = leading && !invoked;
        if (shouldInvokeLeading) {
            fn.apply(this, args);
            invoked = true;
        }

        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => {
            if (trailing && (!leading || invoked)) {
                fn.apply(this, lastArgs);
            }
            invoked = false;
        }, delay);
    };
}

// Leading only — fire immediately, then ignore
const onClick = debounceAdvanced(saveData, 1000, { leading: true, trailing: false });

// Trailing only — fire after quiet period (default)
const onInput = debounceAdvanced(searchApi, 300, { leading: false, trailing: true });

// Both — fire immediately and again after quiet period
const onResize = debounceAdvanced(layoutCalc, 200, { leading: true, trailing: true });

Throttle with trailing edge

function throttleTrailing(fn, interval) {
    let lastTime = 0;
    let timeoutId;
    let lastArgs;

    return function (...args) {
        const now = Date.now();
        const remaining = interval - (now - lastTime);
        lastArgs = args;

        if (remaining <= 0) {
            clearTimeout(timeoutId);
            timeoutId = null;
            lastTime = now;
            fn.apply(this, args);
        } else if (!timeoutId) {
            timeoutId = setTimeout(() => {
                lastTime = Date.now();
                timeoutId = null;
                fn.apply(this, lastArgs);
            }, remaining);
        }
    };
}

// Fires at most once per interval, with a final call after activity stops
const onMouseMove = throttleTrailing(updatePosition, 50);

Cancelable debounce and throttle

function debounceCancelable(fn, delay) {
    let timeoutId;

    const debounced = function (...args) {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => fn.apply(this, args), delay);
    };

    debounced.cancel = () => {
        clearTimeout(timeoutId);
        timeoutId = null;
    };

    debounced.flush = (...args) => {
        clearTimeout(timeoutId);
        fn.apply(this, args);
    };

    return debounced;
}

// Usage
const save = debounceCancelable(autosave, 1000);
input.addEventListener("input", () => save());
button.addEventListener("click", () => save.cancel());  // Cancel pending save

Practical: autosave with debounce

class AutoSave {
    constructor(saveFn, delay = 2000) {
        this.save = debounceCancelable(saveFn, delay);
    }

    onChange(data) {
        this.save(data);
    }

    forceSave(data) {
        this.save.flush(data);
    }

    cancel() {
        this.save.cancel();
    }
}

const autosave = new AutoSave(async (data) => {
    const response = await fetch("/api/save", {
        method: "POST",
        body: JSON.stringify(data),
    });
    console.log("Saved:", await response.json());
});

editor.addEventListener("input", () => autosave.onChange(editor.value));
window.addEventListener("beforeunload", () => autosave.forceSave(editor.value));

Practical: scroll progress with throttle

const updateScrollProgress = throttle(() => {
    const scrollTop = window.scrollY;
    const docHeight = document.documentElement.scrollHeight - window.innerHeight;
    const progress = (scrollTop / docHeight) * 100;
    document.querySelector(".progress-bar").style.width = `${progress}%`;
}, 16);  // ~60fps

window.addEventListener("scroll", updateScrollProgress, { passive: true });

Explanation

Debounce kicks off a fresh timer on every event. The wrapped function only runs after the stream of calls has been quiet for delay milliseconds. That’s when you’d reach for it, while the user is still typing, scrolling, or resizing.

Throttle runs the function on the first call and then skips the rest until interval milliseconds pass. It works well for steady, periodic updates rather than waiting for a pause.

Leading edge fires on the first call; later ones get ignored or rescheduled. Trailing edge runs a final call after the quiet period, using the most recent arguments.

TechniqueFires WhenUse Case
Debounce (trailing)After activity stopsSearch, autosave
Debounce (leading)Immediately, then waitButton click protection
ThrottleAt most once per intervalScroll, mousemove
Throttle (trailing)Once per interval + finalScroll with last position

One thing that trips people up: debounce and throttle aren’t interchangeable. I once reviewed a PR that swapped throttle for debounce on a scroll handler “to make it smoother.” The result was the opposite. The handler didn’t fire until scrolling stopped, so the progress bar jumped in big chunks instead of gliding. The MDN docs on setTimeout explain the timer behavior that both patterns rely on. For a battle-tested implementation, Lodash’s debounce and throttle handle edge cases (leading/trailing, max wait, cancel) that the basic versions above skip.

I learned this the hard way on a dashboard project. We had a resize listener recalculating chart dimensions, and someone “optimized” it with throttle set to 500ms. The charts lagged half a second behind every window resize. Switching to a 150ms debounce fixed it instantly. The lesson: match the pattern to the user expectation, not to what sounds faster.

Variants

PatternBehaviorExample
DebounceDelay until quietSearch input
ThrottleRate limit to intervalScroll handler
RequestAnimationFrameSync with repaintAnimations
IntersectionObserverCallback on visibilityLazy loading

Best Practices

Choose debounce when you need the final value, like in a search field, an autosave routine, or a resize handler. Throttle is the better choice for regular updates, such as a scroll progress bar or a mouse position tracker. For visual updates tied to the browser’s repaint, use requestAnimationFrame instead of throttle. Clear timers when a component unmounts. React’s useEffect cleanup and Vue’s onUnmounted are good places. Add { passive: true } to scroll and touch listeners so the browser doesn’t block the main thread. For button clicks, leading edge gives instant feedback; for search inputs, trailing edge captures the latest query. Test with rapid input to confirm the function doesn’t fire too often.

I also recommend extracting the debounce/throttle wrapper into a shared utility file rather than copy-pasting it into every component. That way, when you find a bug in the implementation (and you will), you fix it once. If you’re working with React forms, a custom useDebounce hook keeps the logic reusable and testable. One more tip: log the call count in development. If you see 50 calls for 10 keystrokes, your wrapper isn’t working and you’ll catch it before users do.

Common Mistakes

A common scroll mistake is using debounce. The handler won’t fire while the user keeps scrolling, so the UI feels frozen. Throttle is better there. Using throttle for a search input is also wrong because the API gets called while the user is still typing. Debounce fits that case. Always clean up timers, or pending timeouts can fire after unmount and throw errors. In throttle, don’t forget to check remaining, or the function may fire at odd times. Skipping { passive: true } on scroll listeners can block scrolling. And if you forget to forward this and args, the wrapped function loses context and arguments. A very long debounce delay makes users think the app is broken, so keep UI feedback delays under one second.

Another one I’ve seen: using Date.now() for throttle timing in a test environment where you mock the clock. The throttle never fires because the mock doesn’t advance. Use a configurable clock injection or test with real timers and small intervals. Also, don’t debounce requestAnimationFrame-driven work. You’re double-gating something that already syncs to the display refresh. I caught this in code review last month. Someone wrapped a rAF loop in a 16ms throttle “for safety” and ended up dropping half the frames.

Summary

Debounce waits for a pause, throttle enforces a rate. Pick debounce for search, autosave, and resize, anything where you want the final value. Pick throttle for scroll, mousemove, and repeated clicks, anything where you want steady updates. Leading edge fires immediately; trailing edge fires after the quiet period. Always clean up timers on unmount, forward this and args, and use requestAnimationFrame for visual work. For production code, reach for Lodash’s battle-tested implementations rather than rolling your own edge cases. If you remember nothing else from this guide: debounce waits, throttle caps. Get that right and the rest is details.

See Also

Frequently Asked Questions

What is the difference between debounce and throttle?

Debounce waits for the user to stop triggering the event, then runs once. Throttle runs at most once per interval, even if the event keeps firing. Pick debounce when you want to wait until the user is done, and throttle when you want to limit the rate. I think of it this way: debounce is "wait for silence," throttle is "cap the speed."

Should I use debounce or throttle for window resize?

Use debounce. You want to recalculate the layout after the user finishes resizing, not on every pixel change. A 150-200ms debounce usually does the job. I've used 200ms on every project and never had a complaint.

How do I implement debounce in React?

A custom hook with useRef stores the timeout across renders. Keep the function reference in another useRef and clear the timeout in the cleanup effect:

function useDebounce(fn, delay) {
    const timeoutRef = useRef(null);
    const fnRef = useRef(fn);
    fnRef.current = fn;

    const debounced = useCallback((...args) => {
        clearTimeout(timeoutRef.current);
        timeoutRef.current = setTimeout(() => fnRef.current(...args), delay);
    }, [delay]);

    useEffect(() => () => clearTimeout(timeoutRef.current), []);

    return debounced;
}
Can I use requestAnimationFrame instead of throttle?

Yes, for visual updates. requestAnimationFrame syncs with the browser's repaint cycle (~60fps). The result is smoother than throttle for animations and scroll-based visual updates. Keep throttle for non-visual work, like API calls.

What is the difference between leading and trailing edge debounce?

Leading-edge debounce fires the function immediately on the first call, then ignores later calls until the wait period expires. Trailing-edge debounce, the default, fires after the wait period when no new calls have come in. I use leading for click events where you want immediate feedback, and trailing for search-as-you-type where you want the latest value. Lodash supports both via { leading: true, trailing: false }. The combo I reach for most often is both edges on a resize handler: leading so the UI updates instantly, trailing so it catches the final size.

Should I cancel pending debounce calls on unmount?

Yes. Always clear the timeout in a cleanup function (useEffect return) to prevent state updates after the component unmounts. This avoids memory leaks and React warnings about setting state on an unmounted component.