TechEarl

JavaScript Debounce (and Throttle): A Modern Implementation Without a Library

A clean modern JavaScript debounce in about ten lines: const, rest params, a leading-edge option, and a cancel method. Plus throttle, when to use which, and the React notes for 2026. No Underscore or lodash needed.

Ishan Karunaratne⏱️ 10 min readUpdated
Share thisCopied
A modern JavaScript debounce function with rest params, a leading-edge option, and a cancel method, plus throttle and the debounce-vs-throttle decision, no library required.

A debounce makes a function wait until the calls stop coming before it actually runs. You wire it onto an event that fires too often (a keystroke, a resize, a scroll), and instead of running on every event it runs once, after a quiet gap you choose. In 2026 you do not need Underscore or lodash for this. The whole thing is about ten lines of standard JavaScript:

javascript
// te_ prefix marks this as a TechEarl example helper
function teDebounce(fn, wait, { leading = false } = {}) {
  let timer;
  return function (...args) {
    const callNow = leading && !timer;
    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      if (!leading) fn.apply(this, args);
    }, wait);
    if (callNow) fn.apply(this, args);
  };
}

Use it by wrapping the handler once and binding the wrapped version:

javascript
const onSearch = teDebounce((event) => {
  fetchResults(event.target.value);
}, 300);

input.addEventListener("input", onSearch);

Type "react", and fetchResults fires once, 300ms after you stop, with the value "react". Not five times, once per letter. That is the entire point: collapse a burst of events into a single call.

The version below is the one I keep reaching for. It uses const, rest params (...args) instead of the old arguments object, a leading edge option, and a .cancel() method so a pending call can be thrown away. Then I cover throttle (a different tool for a different problem), the AbortController variant, the React story, and the this gotcha that bites people who reach for an arrow function.

How the implementation works

Each call clears the previous timer and starts a new one. As long as calls keep arriving inside the wait window, the timer keeps getting reset and fn never runs. The moment a full wait passes with no new call, the timer fires and fn runs with the most recent arguments. fn.apply(this, args) forwards both the this context and the arguments the handler was called with, so the wrapped function behaves like the original.

The leading option flips the timing. With leading: false (the default) the call happens on the trailing edge, after the quiet gap. With leading: true it fires immediately on the first call and then ignores everything until the burst ends. Leading is what you want for something like a "save" button you do not want double-fired: react instantly, then go quiet.

A cancel method (and the AbortController version)

A pending debounced call sometimes needs to be dropped before it runs: a component unmounts, a modal closes, the user navigates away. Hang a .cancel() on the returned function:

javascript
function teDebounce(fn, wait, { leading = false } = {}) {
  let timer;
  const debounced = function (...args) {
    const callNow = leading && !timer;
    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      if (!leading) fn.apply(this, args);
    }, wait);
    if (callNow) fn.apply(this, args);
  };
  debounced.cancel = () => {
    clearTimeout(timer);
    timer = null;
  };
  return debounced;
}

Call onSearch.cancel() in a cleanup path and any queued call is gone. If your codebase already passes AbortSignal around (it is the standard cancellation primitive now, used by fetch, event listeners, and more), you can wire the debounce to a signal instead of exposing your own method:

javascript
function teDebounce(fn, wait, { signal } = {}) {
  let timer;
  signal?.addEventListener("abort", () => clearTimeout(timer));
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
}

const controller = new AbortController();
const onSearch = teDebounce(handleSearch, 300, { signal: controller.signal });
// later, one abort() cancels the pending call and any fetch sharing the signal
controller.abort();

The win is that one controller.abort() tears down the pending timer and any in-flight request that shares the same signal, which kills the stale-response race that plagues search-as-you-type.

Debounce vs throttle: pick by intent

These get conflated constantly, but the rule is short:

  • Debounce: wait for quiet. Run once, after the events stop. Good when you only care about the final state. Search-as-you-type, autosave, validating a field after the user finishes typing.
  • Throttle: at most once per N ms. Run on a fixed cadence while events keep coming. Good when you want steady updates during the activity, not just at the end. A scroll position readout, a progress indicator, a drag handler.

Resize the window with a debounce and your relayout fires once, when you let go. Resize with a throttle and it fires every 200ms during the drag. Same event, opposite behavior, and the wrong one feels broken.

A throttle is also short, no library required:

javascript
function teThrottle(fn, interval) {
  let last = 0;
  let timer;
  let lastArgs;
  let lastThis;
  return function (...args) {
    lastArgs = args;
    lastThis = this;
    const now = Date.now();
    const remaining = interval - (now - last);
    if (remaining <= 0) {
      clearTimeout(timer);
      timer = null;
      last = now;
      fn.apply(this, args);
    } else if (!timer) {
      // trailing call so the final event isn't dropped
      timer = setTimeout(() => {
        last = Date.now();
        timer = null;
        fn.apply(lastThis, lastArgs);
      }, remaining);
    }
  };
}

That fires on the leading edge and guarantees a trailing call, so the last event in a burst is never lost (a common bug in naive throttles that only check elapsed time). Note the lastArgs/lastThis capture: every call refreshes them, so the trailing invocation runs with the most recent event, not the one that happened to schedule the timer. A throttle that closes over the scheduling call's arguments instead will fire stale data on the trailing edge, which is the subtle version of the same bug.

requestAnimationFrame often beats throttle for scroll

If the throttled work is visual, painting, animating, reading layout, do not pick a millisecond interval at all. Use requestAnimationFrame. It runs your callback right before the browser's next paint, so it is effectively a throttle locked to the display's refresh rate (about 16ms at 60Hz, but it self-adjusts) with far better fidelity than a guessed setTimeout value:

javascript
let ticking = false;
window.addEventListener("scroll", () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    updateParallax(window.scrollY);
    ticking = false;
  });
});

The ticking flag is the throttle: at most one update per frame, and that update lands exactly when the browser is about to paint. For a scroll-position readout or a sticky header, this is smoother than any timer-based throttle. Keep the timer-based throttle for non-visual rate limiting (network calls, logging, analytics beacons).

React: useDeferredValue and useDebouncedValue

In React you usually do not hand-roll a debounce around a setState. There are two tools, and they solve different halves of the problem:

  • useDeferredValue defers a low-priority re-render so the input stays responsive while an expensive list updates behind it. No delay to pick, and it is interruptible. But the re-render still happens, so it will not cut your network requests. It is a rendering-priority tool, not a rate limiter.
  • A debounced value (a small custom hook, or useDebouncedValue from a utility library) holds back the state change itself for N ms. That is what actually reduces the number of fetches when the user types.

The pattern I reach for combines them: debounce the value that triggers the network call, and wrap the expensive render in useDeferredValue for the concurrent-rendering smoothness. Use debounce to cut requests, defer to cut jank. The native setTimeout, sleep, and timing toolkit post covers the underlying timer behavior if you want to build the hook yourself.

The arrow-function this caveat

The implementation uses function (...args) for the returned wrapper, not an arrow function, on purpose. An arrow function captures this from where it is defined, so it cannot forward the call site's this to fn. If you wrote the wrapper as an arrow and your debounced handler relies on this (a method on an object, an event handler where this is the element), you would get the wrong context.

The safe rules: keep the returned wrapper a regular function so this flows through fn.apply(this, args), or sidestep the whole issue by binding the method up front (teDebounce(this.handle.bind(this), 300)) or wrapping a class field arrow (handle = () => { ... }) so the function already carries its context. Either is fine. What breaks is making the wrapper itself an arrow and then expecting it to relay this.

Real 2026 use cases

  • Search-as-you-type. Debounce the input by 250 to 400ms so the API is hit once per pause, not per keystroke. Pair it with the AbortController variant above to cancel the previous request and dodge out-of-order responses.
  • Resize. Debounce expensive relayout or chart re-draws on window.resize so the work runs once when the user stops dragging, not hundreds of times mid-drag.
  • Autosave. Debounce a draft save by a second or two after the last edit. Leading-edge throttle if you also want an immediate first save, then a steady cadence.
  • Scroll. Reach for requestAnimationFrame, not a debounce, when the work is visual. Use a throttle (or rAF) for a scroll-linked analytics beacon.

That is the complete toolkit. Ten lines for debounce, a dozen for throttle, requestAnimationFrame for paint-bound work, and the framework hooks where a framework owns the render. The library was never doing anything you cannot read in one screen of code.

FAQ

See also

Sources

Authoritative references this article was fact-checked against.

TagsjavascriptdebouncethrottleperformanceeventsreactAbortController

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts