Programming Tutorials

Understanding Closures by Building a Debounce Function From Scratch

Debounce is the clearest real-world example of a closure actually earning its keep — built from scratch, with the timer-leak bug that showed why the closed-over variable matters.

By Aissam Ait Ahmed Programming Tutorials 0 comments

Closures get explained with toy examples — a counter function, a greeting generator — that demonstrate the mechanism without showing why it matters. Debounce is the opposite: it's a genuinely useful piece of code that simply doesn't work without a closure, which makes it a better way to actually understand what a closure is doing and why it exists, rather than memorizing the definition and forgetting it a week later.

The problem debounce solves

A search-as-you-type input firing an API request on every keystroke sends a request for "c", then "ca", then "cat", then "cat ", each one racing the last, most of them wasted the moment a newer keystroke arrives. Debounce delays the actual action until a pause in activity — wait until the user stops typing for, say, 300 milliseconds, then fire the request once.

A first attempt without a closure — and why it fails

let timerId;

function debounce(fn, delay) {
    clearTimeout(timerId);
    timerId = setTimeout(fn, delay);
}

document.querySelector('#search').addEventListener('input', () => {
    debounce(() => runSearch(input.value), 300);
});

This looks like it should work, and for a single input on a single page, it almost does — but the moment a second debounced input exists anywhere on the same page, both share the exact same module-level timerId variable, and typing in one input cancels the pending timer for the other. The bug isn't in the debounce logic itself; it's that timerId is global to the module rather than private to each specific debounced function, and there's no way to give two different callers of debounce() their own independent timer using this shape at all.

What a closure actually gives you here

A closure lets a function retain access to variables from the scope it was created in, even after that outer scope has technically finished executing — which means each call to a factory function that returns a new function gets its own private, persistent variable that no other call can see or interfere with:

function debounce(fn, delay) {
    let timerId; // private to THIS specific debounced function

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

const debouncedSearch = debounce((query) => runSearch(query), 300);
const debouncedResize = debounce(() => recalculateLayout(), 200);

document.querySelector('#search').addEventListener('input', (e) => {
    debouncedSearch(e.target.value);
});

window.addEventListener('resize', debouncedResize);

debounce() is called twice here, once for search and once for resize, and each call creates its own separate timerId variable living inside its own closure — the inner returned function "closes over" that specific timerId, and no other call to debounce() can see or clear it. This is the entire reason closures exist as a language feature: without them, there'd be no way to give two independently-created functions their own private, persistent state without resorting to a global variable or an object to hold it explicitly.

The bug we actually hit: a timer leak from a missing cleanup

The version above works correctly for the debounce logic itself, and it still has a real bug that showed up once this was used inside a component that could be removed from the page — a search box inside a modal that gets closed and destroyed while a debounce timer is still pending. Closing the modal removed the DOM element and its event listener, but the pending setTimeout callback inside the closure was still scheduled to run, and when it fired, it called fn — which, in our case, updated a piece of state tied to a component that no longer existed, throwing a runtime error caught only by an unrelated global error handler days later.

function debounce(fn, delay) {
    let timerId;

    function debounced(...args) {
        clearTimeout(timerId);
        timerId = setTimeout(() => fn(...args), delay);
    }

    debounced.cancel = () => clearTimeout(timerId); // expose a way to cancel it

    return debounced;
}

const debouncedSearch = debounce((query) => runSearch(query), 300);

modal.addEventListener('close', () => {
    debouncedSearch.cancel(); // called explicitly when the modal closes
});

Attaching a .cancel() method to the returned function — which itself relies on the same closure to access timerId — gives calling code an explicit way to clean up a pending timer before the thing that depends on it disappears. This is a genuinely common gap in debounce implementations copied from quick tutorial examples: they show the debouncing mechanism working, and they skip the cleanup case that only shows up once the debounced function's caller can be torn down while a timer is still pending.

Debounce vs. throttle — a genuinely different closure shape

Throttle is a close cousin that's worth contrasting directly, because the underlying closure shape differs in a way that matters: debounce waits for a pause and fires once after it; throttle fires at most once per fixed interval regardless of how continuously the triggering event fires.

function throttle(fn, interval) {
    let lastCall = 0; // also closed over, also private per call to throttle()

    return function (...args) {
        const now = Date.now();

        if (now - lastCall >= interval) {
            lastCall = now;
            fn(...args);
        }
    };
}

const throttledScroll = throttle(() => updateScrollProgress(), 100);
window.addEventListener('scroll', throttledScroll);

Same closure mechanism — a private variable (lastCall instead of timerId) persisted across calls to the returned function — applied to a different rule for when the wrapped function actually runs. Recognizing that both are the same underlying pattern (a factory function returning a closure over private state) applied to different timing rules makes both easier to remember than treating them as two unrelated techniques to memorize separately.

Where each one actually fits

  • Debounce: search-as-you-type, window resize handlers that trigger expensive recalculation, form validation that shouldn't run on every keystroke.
  • Throttle: scroll position tracking, mouse-move handlers, anything that needs to respond continuously but at a bounded rate rather than only after activity stops.
  • Neither: a button click handler, where the action should fire immediately and exactly once per genuine click — wrapping this in either pattern usually just adds a confusing delay to something that didn't need one.

What actually clicked

The counter and greeting-generator examples that usually introduce closures demonstrate that the mechanism exists without showing what breaks without it. Debounce makes that concrete: the naive, closure-free version above genuinely doesn't work correctly the moment there's more than one debounced function on a page, and the fix isn't a clever trick — it's the closure doing exactly the one job it exists to do, giving each call its own private, persistent state.

Passing arguments through correctly — a subtler closure trap

A second version of the debounce function commonly gets the argument-forwarding wrong in a way that's easy to miss until it's tested with a real, changing value rather than a fixed one:

// Broken: captures whatever `args` happened to be at the LAST call inside
// the timeout callback, but only because of how args is referenced —
// this specific version is actually fine, since args is a parameter
// of the returned function itself, freshly bound on every call:
function debounce(fn, delay) {
    let timerId;

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

// The version that actually breaks this is closing over a shared,
// reassigned variable instead of a function parameter:
function debounceBroken(fn, delay) {
    let timerId;
    let capturedArgs;

    return function (...args) {
        capturedArgs = args; // reassigned on every call, shared across all pending timers
        clearTimeout(timerId);
        timerId = setTimeout(() => fn(...capturedArgs), delay);
    };
}

The working version closes over args as declared fresh on each invocation of the returned function — every call to the debounced function creates its own separate args binding, and the arrow function passed to setTimeout closes over that specific call's binding, not a shared one. The broken version introduces a separate, reassigned outer variable that every pending timer's callback shares, which — because clearTimeout already cancels any truly stale timer in normal debounce usage — usually doesn't surface as a bug in the common case, but becomes a real, confusing one the moment the debounce delay is long enough, or the calling pattern unusual enough, that two callbacks end up genuinely racing against a shared mutable variable instead of each having its own.

A leading-edge variant, and why it needs a third closed-over variable

The debounce implementations above are all "trailing edge" — the function fires once, after the pause. Some use cases (disabling a submit button the instant a user starts typing, before the debounced validation even runs) need a "leading edge" variant that fires immediately on the first call, then ignores subsequent calls until the pause elapses:

function debounceLeading(fn, delay) {
    let timerId;
    let hasFiredThisBurst = false;

    return function (...args) {
        if (!hasFiredThisBurst) {
            fn(...args);
            hasFiredThisBurst = true;
        }

        clearTimeout(timerId);
        timerId = setTimeout(() => {
            hasFiredThisBurst = false; // reset once the pause elapses
        }, delay);
    };
}

This needs a third piece of private state — hasFiredThisBurst — alongside the timer ID, and it's the same closure mechanism doing the work: a variable declared in the outer debounceLeading scope, invisible to any other call to debounceLeading, persisted across every call to the returned inner function for as long as that specific closure exists.

Cleaning up in a component framework

The manual .cancel() pattern shown earlier matters even more inside a component framework like React or Vue, where a component can unmount while a debounce timer is still pending, and calling into a since-unmounted component's state update is a common, specific source of console warnings and, in some cases, real bugs:

useEffect(() => {
    const debouncedSearch = debounce((query) => runSearch(query), 300);

    inputRef.current.addEventListener('input', (e) => debouncedSearch(e.target.value));

    return () => {
        debouncedSearch.cancel(); // runs on unmount, before React tears down state
    };
}, []);

The cleanup function returned from useEffect is exactly the hook meant for this — pairing it with the .cancel() method built earlier closes the same timer-leak gap this post found the hard way, in the specific framework context where it's most likely to actually bite.

If you're working through more from-scratch exercises in this same "build it to actually understand it" spirit, recursion finally clicked when I built a file tree walker covers a different core concept the same way, and this site's own word counter tool is a genuinely reasonable real place to add debounced input handling if you ever build something similar yourself, since recalculating a count on every single keystroke of a long document is exactly the kind of unnecessary repeated work debounce exists to avoid.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Programming Tutorials Free Resources Explore Tools