Ch. 1 · JavaScript

Implement Debounce and Throttle From Scratch

Build debounce and throttle the way interviewers expect: timelines, this and args, cancel(), leading and trailing edges, and how to test them.

~7 min readintermediate

Debounce and throttle are among the most common “implement this” questions in frontend rounds, because they look like five lines of code but quietly test closures, this, timers and edge cases. This note builds both from scratch, starting with the minimal version and ending with the options an interviewer usually asks for next: cancellation, a leading edge and a trailing call that never loses the last event.

Two strategies, one timeline

Both wrap a function and control how often it really runs when it is called in bursts.

  • Debounce waits until the calls stop for wait ms, then runs once with the latest arguments. Every new call resets the timer.
  • Throttle runs at most once per wait ms, however many calls arrive.

Picture a user typing “react” into a search box: keystrokes at 0, 30, 60 and 90 ms, a pause, then the last key at 300 ms. With wait = 100, the implementations in this note produce:

Strategy (wait = 100 ms) The wrapped function runs at
Debounce (trailing, the default) 190 ms with “reac”, 400 ms with “react”
Debounce (leading) 0 ms with “r”, 300 ms with “react”
Throttle (leading + trailing) 0 ms with “r”, 100 ms with “reac”, 300 ms with “react”

Now a continuous stream: a scroll event every 20 ms from 0 to 240 ms. Debounce runs once, at 340 ms. Throttle runs at 0, 100, 200 and 300 ms. That is the whole difference: during a long burst, debounce stays silent, while throttle guarantees steady progress.

When to reach for which

Use case Pick Why
Search-as-you-type, autocomplete Debounce Only the final query matters; saves requests
Validate or autosave while typing Debounce Run once the user pauses
Submit button, double-click guard Debounce with leading: true, trailing: false Fire at once, ignore the rest of the burst
Scroll spy, infinite scroll check Throttle Needs updates during the scroll
Window resize Throttle (or debounce for the final size only) Depends on whether in-between sizes matter
Pointer tracking, drag analytics Throttle A steady sample rate is enough

For purely visual work tied to scroll or resize, requestAnimationFrame is often the better throttle (at most once per frame), and ResizeObserver beats listening to the window resize event when you care about an element’s size.

Interview tip

Open with one sentence: debounce delays until the user stops; throttle guarantees a steady rate. Then give one use case for each. It shows you know why both exist before you write any code.

Debounce, step by step

Version 1: the core idea

function debounce(fn, wait) {
  let timerId;
  return function (...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn.apply(this, args), wait);
  };
}
JavaScript

The returned function is a closure over timerId (the same mechanism behind closures in a setTimeout loop). Each call cancels the pending timer and schedules a new one, so fn only runs once the calls stop for wait ms. Two details matter:

  • The wrapper is a regular function, so it receives whatever this the caller used (binding rules here). The arrow function inside setTimeout has no this of its own, so it sees the wrapper’s this and args, and apply forwards both.
  • clearTimeout(undefined) is a harmless no-op, so the first call needs no special case.

Version 2: leading edge and cancel()

function debounce(fn, wait, { leading = false, trailing = true } = {}) {
  let timerId = null;
  let lastArgs = null;
  let lastThis = null;

  function invoke() {
    const args = lastArgs;
    const ctx = lastThis;
    lastArgs = lastThis = null;
    fn.apply(ctx, args);
  }

  function debounced(...args) {
    lastArgs = args;
    lastThis = this;
    const isNewBurst = timerId === null;
    clearTimeout(timerId);
    timerId = setTimeout(() => {
      timerId = null;
      if (trailing && lastArgs) invoke();
    }, wait);
    if (leading && isNewBurst) invoke();
  }

  debounced.cancel = () => {
    clearTimeout(timerId);
    timerId = lastArgs = lastThis = null;
  };
  return debounced;
}
JavaScript
  • lastArgs and lastThis always hold the most recent call, so the trailing invocation uses fresh data.
  • isNewBurst is true when no timer is pending: that is the moment a leading call fires.
  • With { leading: true } (trailing still on), a single click runs fn once, not twice: invoke() clears lastArgs, so the trailing timer finds nothing to do. A real burst runs on both edges. Lodash behaves the same way.
  • cancel() drops the timer and the pending arguments. Call it on unmount or route change.
const search = debounce((q) => console.log('search:', q), 300);
search('r');
search('re');
search('rea');
// after 300 ms of silence: search: rea
JavaScript

Gotcha

A debounced function cannot return fn’s result, because fn runs later. If the caller needs the value, pass a callback or have fn resolve a promise. Lodash’s version returns the result of the previous invocation, which surprises people.

Try it yourself: debounce challenge.

Throttle, three ways

Timestamp: leading edge only

function throttle(fn, wait) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= wait) {
      last = now;
      fn.apply(this, args);
    }
  };
}
JavaScript

It runs immediately, then drops calls until wait has passed. Simple, but the final call is lost: in the typing timeline it runs at 0 ms (“r”) and 300 ms (“react”), and “reac” never arrives. For scroll handlers that means the UI may never see the final position.

Timer: trailing edge only

function throttle(fn, wait) {
  let timerId = null;
  let lastArgs = null;
  let lastThis = null;
  return function (...args) {
    lastArgs = args;
    lastThis = this;
    if (timerId !== null) return;
    timerId = setTimeout(() => {
      timerId = null;
      fn.apply(lastThis, lastArgs);
    }, wait);
  };
}
JavaScript

It runs at the end of each window with the latest arguments (100 ms with “reac”, 400 ms with “react”), so the final value always lands, but even the first call waits up to wait ms.

Both edges: what you usually want

function throttle(fn, wait) {
  let last = 0;
  let timerId = null;
  let pendingArgs = null;
  let pendingThis = null;

  function throttled(...args) {
    const remaining = wait - (Date.now() - last);
    if (remaining <= 0) {
      clearTimeout(timerId);
      timerId = null;
      last = Date.now();
      fn.apply(this, args);
    } else {
      pendingArgs = args;
      pendingThis = this;
      timerId ??= setTimeout(() => {
        last = Date.now();
        timerId = null;
        fn.apply(pendingThis, pendingArgs);
      }, remaining);
    }
  }
  throttled.cancel = () => {
    clearTimeout(timerId);
    timerId = null;
    last = 0;
  };
  return throttled;
}
JavaScript

remaining is the time until the window reopens. If it is open, run now. Otherwise store the latest arguments and schedule a single trailing call for exactly when the window reopens; ??= guarantees only one timer is pending. The trailing call updates last, so the next window is measured from when fn really ran. The clearTimeout in the first branch covers a timer that fires late, since setTimeout delays are minimums, not promises.

Note

Date.now() follows the system clock, which can jump when it syncs or the user changes it. performance.now() is monotonic. Either is fine in an interview, but naming the difference is a nice senior touch. Also worth knowing: Lodash implements throttle as a debounce with a maxWait equal to wait.

Try it yourself: throttle challenge.

Common bugs

  • A new wrapper on every call or render. Each debounced function owns its own timer, so creating one inside a React component body means nothing is ever debounced. Create it once and clean up:
// Bug: a new debounced function, with a new timer, on every render
const onChange = debounce((e) => search(e.target.value), 300);

// Fix: one instance for the component's lifetime
const onChange = useMemo(() => debounce((e) => search(e.target.value), 300), []);
useEffect(() => () => onChange.cancel(), [onChange]);
JavaScript
  • Returning an arrow function. return (...args) => { ... } makes this whatever it was when debounce itself ran, usually undefined, so methods break.
  • Forgetting clearTimeout. Every call still runs, just wait ms later.
  • Stale arguments. A trailing call that uses the first call’s arguments reports an old scroll position. Overwrite the stored arguments on every call.
  • Calling instead of passing. debounce(save(), 500) passes save’s return value. Pass save.
  • No cleanup. A pending trailing call can fire after unmount or navigation. Expose cancel() and use it.
  • Wrong tool. Debouncing a scroll-driven UI shows nothing until the user stops scrolling. That is a throttle job.

Testing with fake timers

Real waits make tests slow and flaky. Fake timers move the clock deterministically. With Node’s built-in test runner:

import { test, mock } from 'node:test';
import assert from 'node:assert/strict';

test('runs once, after the pause, with the latest args', () => {
  mock.timers.enable({ apis: ['setTimeout'] });
  const spy = mock.fn();
  const search = debounce(spy, 300);

  search('r');
  search('re');
  search('rea');
  mock.timers.tick(299);
  assert.equal(spy.mock.callCount(), 0);

  mock.timers.tick(1);
  assert.equal(spy.mock.callCount(), 1);
  assert.deepEqual(spy.mock.calls[0].arguments, ['rea']);
  mock.timers.reset();
});
JavaScript

Jest uses jest.useFakeTimers() and jest.advanceTimersByTime(300); Vitest uses vi.useFakeTimers() and vi.advanceTimersByTime(300). Check the boundary (299 vs 300 ms), this forwarding, cancel(), and each leading/trailing combination. Throttle also reads Date.now(), so the clock must be faked too: in Node add 'Date' to apis; Jest and Vitest fake Date by default.

The interview answer

“Both limit how often a function runs during a burst of calls. Debounce waits until the calls stop for wait milliseconds and then runs once with the latest arguments, which is right for search-as-you-type. Throttle runs at most once per wait, which suits scroll and resize handlers that need regular updates while the burst is still going.

Both are closures over a timer. I return a regular function so I can forward this and the arguments with apply, I keep the latest arguments for the trailing call, and I expose cancel() so a component can clean up on unmount. For throttle, I track when it last ran and schedule one trailing call for the remaining time, so the final event is never dropped. And I’d test it with fake timers rather than real waits.”

More in JavaScript

read ✓JavaScript · mid

Closures, Scope & the Classic setTimeout Loop

What lexical scope and closures really are, why the var + setTimeout loop prints 3 3 3, three ways to fix it, and where closures earn their keep in real code.

~6 min readread →
read ✓JavaScript · hard

The Event Loop: Microtasks vs Macrotasks

How the call stack, task queue and microtask queue fit together, where rendering happens, how async/await schedules work, and output puzzles with answers.

~7 min readread →
esc