TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

JavaScript Debounce vs Throttle: Build Both

Practical insights, clear explanations, and useful ideas for understanding the web.

October 26, 20268 min read5 topics
JavaScript Debounce vs Throttle: Build Both
Explore the details
#JavaScript#debounce#throttle#frontend#performance
October 26, 20268 min read

Debounce and throttle both limit how often a function runs, but they respond to repeated events differently. Choose debounce when you need to wait for activity to stop, such as before searching. Choose throttle when you want regular updates during ongoing activity, such as while scrolling. This tutorial builds both utilities, explains leading and trailing execution, and shows how to clean up their timers.

Key Takeaways

  • Debounce runs after calls have stopped for a specified interval. Each new call restarts the timer.
  • Throttle limits execution to at most once per interval, with optional execution at the start (leading) or end (trailing) of a period.
  • Leading and trailing options affect when calls run; they do not change debounce into throttle or vice versa.
  • Keep the returned wrapper when you need to remove an event listener, and cancel its timer during cleanup.
  • These implementations are provided with a runnable Node.js test file, but I could not execute the tests in this writing environment.

Debounce vs. throttle: the timing difference

Suppose a user triggers an event repeatedly, faster than once every 200 milliseconds.

BehaviorWhat happensTypical use
DebounceWaits until there have been 200 ms without a call, then runs once with the latest call's arguments.Search input, autosave after typing
ThrottleAllows at most one run per 200 ms, optionally at the start, end, or both points in each period.Scroll and pointer updates

Debounce postpones work until activity pauses. Throttle allows work to happen during sustained activity, but limits its frequency. Neither guarantees execution at an exact wall-clock time: timers may run later if the browser is busy or the page is in the background.

Build a debounce function

This implementation supports leading and trailing execution, preserves the calling context and arguments, and exposes cancel, flush, and pending methods. It uses only built-in JavaScript timers.

// Save as rate-limit.mjs
export function debounce(fn, wait, { leading = false, trailing = true } = {}) {
  if (typeof fn !== "function") {
    throw new TypeError("fn must be a function");
  }
  if (!Number.isFinite(wait) || wait < 0) {
    throw new RangeError("wait must be a finite, non-negative number");
  }

  let timerId;
  let lastArgs;
  let lastThis;
  let result;

  function invoke() {
    const args = lastArgs;
    const context = lastThis;
    lastArgs = undefined;
    lastThis = undefined;
    result = fn.apply(context, args);
    return result;
  }

  function onTimeout() {
    timerId = undefined;
    if (trailing && lastArgs !== undefined) {
      invoke();
    } else {
      lastArgs = undefined;
      lastThis = undefined;
    }
  }

  function debounced(...args) {
    const isIdle = timerId === undefined;
    lastArgs = args;
    lastThis = this;

    if (timerId !== undefined) {
      clearTimeout(timerId);
    }
    timerId = setTimeout(onTimeout, wait);

    if (leading && isIdle) {
      invoke();
    }

    return result;
  }

  debounced.cancel = () => {
    if (timerId !== undefined) {
      clearTimeout(timerId);
    }
    timerId = undefined;
    lastArgs = undefined;
    lastThis = undefined;
  };

  debounced.flush = () => {
    if (timerId === undefined) {
      return result;
    }
    clearTimeout(timerId);
    timerId = undefined;
    if (trailing && lastArgs !== undefined) {
      return invoke();
    }
    lastArgs = undefined;
    lastThis = undefined;
    return result;
  };

  debounced.pending = () => timerId !== undefined;

  return debounced;
}

With the default options, calls made close together replace the pending arguments and restart the timer. Once the calls stop for wait milliseconds, the function runs once with the most recent arguments.

A leading-only debounce runs immediately on the first call in a burst and ignores later calls in that burst. With both leading and trailing enabled, the first call runs immediately; a trailing run occurs only if another call arrives before the timer expires. That avoids running twice for a single call.

Debounce a search input

import { debounce } from "./rate-limit.mjs";

const searchInput = document.querySelector("#search");

const runSearch = debounce((query) => {
  console.log("Search for:", query);
}, 300);

searchInput.addEventListener("input", (event) => {
  runSearch(event.currentTarget.value.trim());
});

The handler waits until the user pauses typing for 300 ms before logging the latest query. In a real application, the callback could start a request. Debouncing does not cancel a request that has already started, nor does it prevent older responses from arriving after newer ones; use request cancellation or response ordering where that matters.

Build a throttle function

This throttle uses a cooldown timer. Its default behavior is leading and trailing: it runs immediately, then, if calls arrive during the cooldown, runs once more with the latest call's arguments when that cooldown ends.

// Add to rate-limit.mjs
export function throttle(fn, wait, { leading = true, trailing = true } = {}) {
  if (typeof fn !== "function") {
    throw new TypeError("fn must be a function");
  }
  if (!Number.isFinite(wait) || wait < 0) {
    throw new RangeError("wait must be a finite, non-negative number");
  }

  let timerId;
  let lastArgs;
  let lastThis;
  let result;

  function onTimeout() {
    timerId = undefined;

    if (trailing && lastArgs !== undefined) {
      const args = lastArgs;
      const context = lastThis;
      lastArgs = undefined;
      lastThis = undefined;

      // Start the next cooldown before calling fn, including if fn calls the
      // throttled wrapper again synchronously.
      timerId = setTimeout(onTimeout, wait);
      result = fn.apply(context, args);
    } else {
      lastArgs = undefined;
      lastThis = undefined;
    }
  }

  function throttled(...args) {
    if (timerId === undefined) {
      if (leading) {
        timerId = setTimeout(onTimeout, wait);
        result = fn.apply(this, args);
      } else {
        lastArgs = args;
        lastThis = this;
        timerId = setTimeout(onTimeout, wait);
      }
    } else {
      lastArgs = args;
      lastThis = this;
    }

    return result;
  }

  throttled.cancel = () => {
    if (timerId !== undefined) {
      clearTimeout(timerId);
    }
    timerId = undefined;
    lastArgs = undefined;
    lastThis = undefined;
  };

  return throttled;
}

With leading: true and trailing: false, only the first call in each cooldown runs. With leading: false and trailing: true, the first call waits until the end of the initial interval, then runs with the latest arguments. If both options are false, no call runs. A trailing execution starts a new cooldown, so calls immediately after that execution do not run early.

Throttle a scroll handler and clean it up

import { throttle } from "./rate-limit.mjs";

const onScroll = throttle(() => {
  console.log("Scroll position:", window.scrollY);
}, 100);

window.addEventListener("scroll", onScroll);

// Call this when the component or feature is removed.
function cleanup() {
  window.removeEventListener("scroll", onScroll);
  onScroll.cancel();
}

Keep the same onScroll reference for both adding and removing the listener. Calling cancel() clears a pending trailing call and releases its saved arguments and context. The debounce wrapper has the same cleanup method; its flush() method instead cancels the pending timer and immediately performs a pending trailing call.

Run the included tests

The implementations above are ES modules. Save them in rate-limit.mjs. Save this test file beside it as rate-limit.test.mjs, then run node --test rate-limit.test.mjs in a Node.js version with the built-in node:test module.

import test from "node:test";
import assert from "node:assert/strict";
import { debounce, throttle } from "./rate-limit.mjs";

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

test("debounce runs once with the last arguments", async () => {
  const calls = [];
  const run = debounce((value) => calls.push(value), 50);

  run("first");
  run("last");
  await delay(80);

  assert.deepEqual(calls, ["last"]);
});

test("debounce leading and trailing runs only when there is a later call", async () => {
  const calls = [];
  const run = debounce((value) => calls.push(value), 50, {
    leading: true,
    trailing: true,
  });

  run("first");
  run("last");
  await delay(80);

  assert.deepEqual(calls, ["first", "last"]);
});

test("cancel prevents a pending debounced call", async () => {
  const calls = [];
  const run = debounce((value) => calls.push(value), 50);

  run("cancel me");
  run.cancel();
  await delay(80);

  assert.deepEqual(calls, []);
});

test("throttle runs immediately and then with the latest pending arguments", async () => {
  const calls = [];
  const run = throttle((value) => calls.push(value), 50);

  run("first");
  run("middle");
  run("last");
  await delay(80);

  assert.deepEqual(calls, ["first", "last"]);
});

These are timing-based checks, so test results can be affected by a heavily loaded machine. I could not execute this test file here; run it in your target Node.js environment to verify behavior there.

Common Mistakes

  • Using debounce for continuous feedback. A debounced scroll handler may not run until scrolling stops. Use throttle when updates should happen during activity.
  • Assuming throttle always runs on the leading edge. Its leading behavior depends on the chosen options. Check the wrapper configuration, especially when the first update must be immediate.
  • Expecting every call to be preserved. Both utilities intentionally discard some calls. These examples retain only the latest pending arguments for a trailing run.
  • Removing a different event-handler function. removeEventListener needs the same function reference that was passed to addEventListener.
  • Forgetting pending work on teardown. Remove the listener and call cancel() so a timer does not invoke a callback after its owner has been removed.
  • Treating a debounce delay as request cancellation. It delays starting work; it does not undo work already in progress.
  • Assuming timers run exactly on schedule. setTimeout specifies a minimum delay, not a guarantee of execution at a precise instant.

Best Practices

  • Choose based on when useful work should happen: after a pause (debounce) or at a limited rate during activity (throttle).
  • Pick leading and trailing behavior explicitly for interactions where the first or final update matters.
  • Keep the wrapper in a stable variable, particularly when registering and cleaning up event listeners.
  • Use cancel() during teardown. Use debounce's flush() only when an immediate final invocation is appropriate.
  • Test the behavior users depend on, including a single call, a burst of calls, cleanup before the timer fires, and the selected leading/trailing options.

Conclusion

Debounce waits for a quiet period; throttle limits repeated execution across time. Build the one that matches the interaction, choose leading and trailing behavior deliberately, and clean up its timer along with its event listener.