Debounce With Leading + Trailing Edges and a cancel() Method

The trailing-only debounce in every tutorial works for search inputs and breaks on click handlers. Here is the lodash-style version with leading edge, cancel(), and flush(), in 30 lines.

JavaScript
Frontend
3 snippets
throttling
performance
code-template
hiroshiward

By @hiroshiward

December 18, 2025

·

Updated May 18, 2026

915 views

13

4.4 (8)

// The production debounce I keep in every project. Defaults match lodash:
// leading=false, trailing=true. The two extra methods (cancel, flush) are
// what makes it survive contact with React unmount and form-submit handlers.

function debounce(fn, wait, { leading = false, trailing = true } = {}) {
    let timer = null;
    let lastArgs = null;
    let lastThis = null;
    let result;

    function invoke() {
        const args = lastArgs; const ctx = lastThis;
        lastArgs = null; lastThis = null;
        result = fn.apply(ctx, args);
    }

    function debounced(...args) {
        const callNow = leading && timer === null;
        lastArgs = args; lastThis = this;
        if (timer !== null) clearTimeout(timer);
        timer = setTimeout(() => {
            timer = null;
            if (trailing && lastArgs) invoke();
        }, wait);
        if (callNow) invoke();
        return result;
    }

    debounced.cancel = () => {
        if (timer !== null) clearTimeout(timer);
        timer = null; lastArgs = null; lastThis = null;
    };
    debounced.flush = () => {
        if (timer !== null) {
            clearTimeout(timer); timer = null;
            if (lastArgs) invoke();
        }
        return result;
    };
    return debounced;
}

let n = 0;
const log = debounce((label) => { n++; console.log(`fired: ${label} #${n}`); }, 50);

log('a'); log('b'); log('c');
setTimeout(() => log('d'), 100);
setTimeout(() => { log('e'); log('f'); log.flush(); }, 200);
setTimeout(() => { log('g'); log.cancel(); }, 300);
setTimeout(() => console.log('total fires:', n), 400);

The four behaviors compose naturally on top of one timer reference. Leading is if (leading && timer === null), trailing is the contents of the timeout callback, cancel clears the timer and forgets the queued args, and flush clears the timer but invokes anyway with the queued args. The most common bug I have shipped in this code is forgetting to capture this and args together: a debounced method on a class instance must keep both for the fn.apply(ctx, args) call inside invoke, or you lose the this binding silently. The result-caching is a small lodash-compat detail that lets you read the previous return value off a debounced getter; few callers use it but its absence is surprising.