Blog / Coding tips

JavaScript Debounce Function Example, Explained

Cover image for JavaScript Debounce Function Example, Explained

A debounce function in JavaScript wraps another function so that it only runs once the calls have stopped for a set time, such as 300 milliseconds. Every new call cancels the previous timer with clearTimeout() and starts a fresh one with setTimeout(), so a burst of ten keystrokes turns into one search instead of ten. Below is a short debounce function example you can copy, the output from actually running it, a leading-edge version for buttons, and a live search box that uses it.

I first needed this on my portfolio when I added a search box to my blog list. Every key press was filtering the list and re-drawing it, which felt jumpy on my old phone. Debouncing fixed it in ten lines with no library. All the code below was run with Node 20 and in headless Chrome, and the outputs are copied straight from the terminal.

The debounce function (copy and paste)

Here's the whole thing. It takes the function you want to delay (fn) and how long to wait in milliseconds (wait), and gives you back a new function to call instead:

// debounce.js: run fn only after calls stop for `wait` ms
function debounce(fn, wait = 300) {
  let timer = null;

  function debounced(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      fn.apply(this, args);
    }, wait);
  }

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

  return debounced;
}

module.exports = { debounce };

Three small details make this version safer than the one-liners you often see:

  • It uses a normal function for debounced, not an arrow function, so this is whatever object you called it on. That matters for methods, as you'll see below.
  • It passes on every argument with ...args, and the last call's arguments win. For a search box, that's the final text the user typed.
  • It has a cancel() method, so you can stop a pending call, for example when a modal closes or a component is removed.

How debounce works, step by step

The trick is a closure. The timer variable lives inside debounce(), and every call to the returned function shares that one variable. On each call:

  1. clearTimeout(timer) cancels the call that was waiting, if there was one.
  2. setTimeout() schedules a new call wait milliseconds from now.
  3. If another call arrives before the time is up, go back to step 1. The clock restarts.
  4. When the calls finally stop for wait ms, the timer fires and fn runs once with the latest arguments.

If closures still feel a bit magic, it helps to know that setTimeout() doesn't block anything. It just puts a callback on a queue, the same idea I explain in my JavaScript async/await tutorial for beginners. MDN's page on setTimeout() is worth a read too, especially the part about the returned timer ID.

Debounce example: simulating fast typing

To see it working without a browser, I faked someone typing "css", pausing, and then typing " grid". Each "key press" is a setTimeout at a set time, and the log shows the time rounded to 100 ms:

const { debounce } = require("./debounce");

const start = Date.now();
const log = (msg) => console.log(`${String(Math.round((Date.now() - start) / 100) * 100).padStart(4)}ms  ${msg}`);

const search = debounce((query) => log(`search for "${query}"`), 300);

// Pretend someone types "css" quickly, pauses, then types "grid"
const keys = [
  [0, "c"], [100, "cs"], [200, "css"],
  [800, "css g"], [900, "css gr"], [1000, "css gri"], [1100, "css grid"],
];
for (const [at, text] of keys) {
  setTimeout(() => { log(`typed "${text}"`); search(text); }, at);
}

// Output:
//    0ms  typed "c"
//  100ms  typed "cs"
//  200ms  typed "css"
//  500ms  search for "css"
//  800ms  typed "css g"
//  900ms  typed "css gr"
// 1000ms  typed "css gri"
// 1100ms  typed "css grid"
// 1400ms  search for "css grid"

Seven calls went in, but the search only ran twice: 300 ms after "css" and 300 ms after "css grid". The calls in between were cancelled because a newer one replaced them before the timer ran out. That's exactly what you want for a search box, an autosave, or a resize handler.

A real search box with debounce

Here's the browser version I tested. It filters a small list of post titles, but the showResults function could just as easily call an API. The debounce-browser.js file is the same function as above without the module.exports line:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Debounced search</title>
</head>
<body>
  <label for="q">Search tips</label>
  <input id="q" type="search" autocomplete="off">
  <ul id="results"></ul>

  <script src="debounce-browser.js"></script>
  <script>
    const tips = ["CSS flexbox vs grid", "CSS position sticky", "JavaScript async/await", "JavaScript map and filter", "PHP PDO"];
    const input = document.getElementById("q");
    const list = document.getElementById("results");

    function showResults(query) {
      const q = query.trim().toLowerCase();
      const matches = q ? tips.filter((t) => t.toLowerCase().includes(q)) : [];
      list.replaceChildren(...matches.map((t) => {
        const li = document.createElement("li");
        li.textContent = t;
        return li;
      }));
      console.log(`searched "${query}": ${matches.length} result(s)`);
    }

    const debouncedSearch = debounce(showResults, 300);
    input.addEventListener("input", (e) => debouncedSearch(e.target.value));
  </script>
</body>
</html>

I ran it in headless Chrome and sent three input events 50 ms apart ("c", "cs", "css"). The console only logged one search, and the list showed the right results:

searched "css": 2 result(s)
list: CSS flexbox vs grid | CSS position sticky

Notice that the debounced function is created once, outside the event listener. A common bug is writing input.addEventListener("input", (e) => debounce(showResults, 300)(e.target.value)), which makes a brand new debounce with its own timer on every key press, so nothing is ever cancelled. If you build forms like this, my guide to HTML form input types and validation covers the type="search" input and friends.

Keeping this and cancelling a call

Because debounced is a regular function that calls fn.apply(this, args), you can use it as a method and this still points at the object. The cancel() method stops a pending call completely:

const { debounce } = require("./debounce");

const form = {
  name: "contact form",
  autosave: debounce(function (field) {
    console.log(`${this.name}: autosaved ${field}`);
  }, 200),
};

form.autosave("email");
form.autosave("message"); // only this one runs, and `this` is still `form`

const ping = debounce(() => console.log("this should never print"), 200);
ping();
ping.cancel(); // e.g. the user left the page before the timer fired

setTimeout(() => console.log("done"), 400);

// Output:
// contact form: autosaved message
// done

Only the last autosave ran, it could read this.name, and the cancelled ping never printed. If you had used an arrow function inside the wrapper and called fn(...args), this.name would be undefined.

Leading-edge debounce for buttons

The version above is a trailing debounce: it waits for the burst to end. For a "Save" or "Pay" button you usually want the opposite. The first click should work straight away, and the extra nervous clicks should be ignored. That's a leading-edge debounce:

// Leading-edge debounce: run on the FIRST call, ignore the rest of the burst
function debounceLeading(fn, wait = 300) {
  let timer = null;
  return function (...args) {
    if (timer === null) fn.apply(this, args);
    clearTimeout(timer);
    timer = setTimeout(() => { timer = null; }, wait);
  };
}

const start = Date.now();
const save = debounceLeading(() => {
  console.log(`saved at ~${Math.round((Date.now() - start) / 100) * 100}ms`);
}, 300);

// Five nervous clicks, 50ms apart, then one more click a second later
[0, 50, 100, 150, 200, 1200].forEach((at) => setTimeout(save, at));

// Output:
// saved at ~0ms
// saved at ~1200ms

Five clicks in 200 ms caused one save, at the very start. A click a second later counted again, because the 300 ms quiet period had passed. For a form that sends data to a server, I'd still disable the button while the request is in flight, but this stops accidental double submits.

Debounce vs throttle: which one do you need?

People mix these up, so here's the short version:

  • Debounce waits until things go quiet, then runs once. Use it for search boxes, autosave, form validation as you type, and window resize.
  • Throttle runs at most once every X ms while things are happening. Use it for scroll position, mouse tracking or progress bars, where you want regular updates during the action.

A quick test: if the user would be annoyed that nothing updates until they stop, you want throttle. If they'd be annoyed by flicker or wasted requests, you want debounce.

Choosing the wait time

There's no magic number, but these are the values I've settled on after trying them on my own projects:

  • Search and filter as you type: 250–300 ms. Shorter fires mid-word; longer feels laggy.
  • Autosave a text area: 800–1000 ms, so it saves when the user pauses to think.
  • Window resize: 150–200 ms.
  • Checking a username is free: about 500 ms, since each check is a network request.

Common mistakes

  • Creating the debounce inside the handler. Each call gets a new timer, so nothing is ever cancelled. Create it once and reuse it.
  • Debouncing in React without useMemo or useRef. A re-render creates a new function, which has the same effect as the mistake above.
  • Expecting a return value. The real work happens later, so debounced() returns undefined. Put the result in the DOM or state inside fn, or wrap the timer in a Promise if you really need one.
  • Forgetting to cancel. If the element is removed, call cancel() so the timer doesn't run code against something that no longer exists.

FAQ

What is a debounce function in JavaScript?

It's a wrapper that delays running a function until it hasn't been called for a set time. Each call resets the timer, so a rapid burst of calls results in a single run with the most recent arguments.

How do I debounce a function with arguments?

Collect them with rest parameters (...args) in the wrapper and pass them on with fn.apply(this, args) when the timer fires, as in the example above. The arguments from the last call are the ones used.

Should I use lodash debounce or write my own?

If lodash is already in your project, _.debounce is well tested and supports leading, trailing and maxWait options. If not, the ten-line version here covers most cases without adding a dependency.

What is the difference between debounce and setTimeout?

setTimeout() just delays one call. Debounce uses setTimeout() plus clearTimeout() so that each new call cancels the one before it, which is what turns many calls into one.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related notes

How to Work Out Your UK Degree Classification
Coding tips

How to Work Out Your UK Degree Classification

Work out a First, 2:1 or 2:2 from module marks: credit-weighted averages, 30:70 or 1:2 year weightings, rounding at 69.5 and the mark you need.

October 4, 2026 · 10 min read