JavaScript Debounce

Why It Needs a Closure


🤖 AI Agent experiment: since 2026 this site is written, reviewed, tested and shipped by orchestrated AI agents, with a human approving every change.

A search input that calls your API on every keystroke is calling it far more than it needs to. Type "banana" and you just fired six requests, and five of them get thrown away before they even come back, because the only answer you care about is the one for the full word.

We will write a debounce function from scratch to fix that, then look at why it only works because of a closure, not in spite of one.

The Problem

A plain event listener runs on every single keystroke, no matter how fast you type.

function handleSearch(event) {
  fetchResults(event.target.value)
}

input.addEventListener("input", handleSearch)

Each keystroke is its own event, so handleSearch has no idea a previous call even happened. It just fires fetchResults again, every time, with whatever the input holds at that instant.

Writing debounce

debounce wraps a function so that it only actually runs once the calls stop coming in for a while.

function debounce(fn, delay) {
  let timeoutId

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

const debouncedSearch = debounce(handleSearch, 300)

input.addEventListener("input", debouncedSearch)

Every keystroke calls the returned function, and that function immediately cancels whatever timer the previous keystroke started with clearTimeout, then schedules a brand new one with setTimeout. As long as you keep typing within 300ms of the last key, the timer never gets the chance to fire. fetchResults only runs once, 300ms after you stop.

Why it needs a closure

The part doing all the work is timeoutId. It is declared once, inside debounce, and the inner function keeps reading and writing that same variable across calls instead of getting a fresh one each time. That is a closure, the returned function remembers the scope it was created in even after debounce has already finished running.

If timeoutId lived outside debounce instead, every debounced function you create would share the same timer.

let timeoutId

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

const debouncedSearch = badDebounce(handleSearch, 300)
const debouncedResize = badDebounce(handleResize, 300)

Now typing in the search box cancels a pending resize call, and resizing the window cancels a pending search, because both wrapped functions are fighting over one shared timeoutId. Putting the variable inside debounce gives each call its own private timer that nothing else can touch.

Conclusion

debounce looks like a timing trick, but the actual mechanism is a closure holding one private variable per call. Get that variable in the wrong scope and the whole thing breaks in a way that only shows up once you have more than one debounced function running at the same time. If you want the deeper version of why a variable can outlive the function it was declared in, I already wrote about that in this post about closures.

Have a nice code!

Comments