Implement throttle in JavaScript

Throttle wraps a function so it runs at most once per fixed time window, no matter how often it’s called. The first call runs immediately and opens a window; calls arriving inside that window are dropped. With trailing: true, the last dropped call runs when the window closes.

Easy to hard closures timers higher-order functions

The question

Throttle almost always follows debounce. Write throttle(callback, wait), add cancel() to close the current window early, then add the leading and trailing options lodash exposes.

That last part is where most people stall. The four combinations of leading and trailing behave quite differently, and you have to reason them out rather than recall them.

The solution

The window, not the call, is the thing being tracked. An open window is represented by a live timer id, which is also what cancel() tears down.

function throttle(callback, wait = 0, options = {}) {
  // Leading is on unless switched off; trailing is off unless switched on.
  const leading = options.leading !== false;
  const trailing = options.trailing === true;

  let timeoutId = null;
  let pendingArgs = null;
  let pendingThis = null;

  function openWindow() {
    timeoutId = setTimeout(() => {
      timeoutId = null;
      if (trailing && pendingArgs !== null) {
        const args = pendingArgs;
        const context = pendingThis;
        pendingArgs = null;
        pendingThis = null;
        callback.apply(context, args);
        // A trailing call starts its own window, so the rate holds.
        openWindow();
      }
    }, wait);
  }

  function throttled(...args) {
    if (timeoutId === null) {
      if (leading) {
        callback.apply(this, args);
      } else {
        pendingArgs = args;
        pendingThis = this;
      }
      openWindow();
      return;
    }
    // Inside the window: remember the call in case trailing is on.
    pendingArgs = args;
    pendingThis = this;
  }

  throttled.cancel = () => {
    if (timeoutId !== null) clearTimeout(timeoutId);
    timeoutId = null;
    pendingArgs = null;
    pendingThis = null;
  };

  return throttled;
}

How it works

The timer represents the window, not the call. While timeoutId is non-null the window is open and anything arriving is dropped. Leading defaults to on and trailing defaults to off, matching lodash and matching what people expect when they pass no options.

A dropped call still gets remembered in pendingArgs, so trailing: true has something to run when the window closes. That trailing call then opens a window of its own. Skip that and a steady stream of calls can produce two invocations back to back, which breaks the only guarantee throttle makes.

The tests it has to pass

This is the part most explanations leave out. "It works" is not a claim you can check by reading. These are the 9 cases Practice Pad runs against your solution, and the implementation above passes all of them.

Easy3 cases · the window behaviour

runs the first call immediately

let seen;
const throttled = throttle((value) => { seen = value; }, 40);
throttled("first");
expectEqual(seen, "first");

ignores calls made while the wait is still running

const seen = [];
const throttled = throttle((value) => { seen.push(value); }, 60);
throttled("a");
throttled("b");
throttled("c");
expectEqual(seen, ["a"]);

passes every argument of the call that runs

const seen = [];
const throttled = throttle((...args) => { seen.push(args); }, 60);
throttled(1, 2, 3);
expectEqual(seen, [[1, 2, 3]]);
Medium3 cases · cancel()

cancel reopens the window so the next call runs at once

const seen = [];
const throttled = throttle((value) => { seen.push(value); }, 100);
throttled("first");
throttled("blocked");
throttled.cancel?.();
throttled("after cancel");
expectEqual(seen, ["first", "after cancel"]);

cancel with an open window does not itself call through

let calls = 0;
const throttled = throttle(() => { calls += 1; }, 40);
throttled.cancel?.();
expectEqual(calls, 0);
throttled();
expectEqual(calls, 1);

the window still closes normally after a cancel

let calls = 0;
const throttled = throttle(() => { calls += 1; }, 60);
throttled();
throttled.cancel?.();
throttled();
throttled();
expectEqual(calls, 2);
Hard3 cases · leading and trailing

leading false holds the first call until the window closes

const seen = [];
const throttled = throttle((value) => { seen.push(value); }, 50, {
  leading: false,
  trailing: true,
});
throttled("a");
expectEqual(seen, []);
await delay(120);
expectEqual(seen, ["a"]);

trailing true runs the last dropped call when the window closes

const seen = [];
const throttled = throttle((value) => { seen.push(value); }, 50, { trailing: true });
throttled("first");
throttled("dropped");
throttled("last");
expectEqual(seen, ["first"]);
await delay(120);
expectEqual(seen, ["first", "last"]);

trailing stays off by default, so dropped calls never run

const seen = [];
const throttled = throttle((value) => { seen.push(value); }, 50);
throttled("first");
throttled("dropped");
await delay(120);
expectEqual(seen, ["first"]);

Edge cases to watch out for

Using a timestamp instead of a timer, then forgetting trailing

Comparing Date.now() against the last call time is a perfectly good way to implement the leading-only case, and it’s what most candidates reach for. It can’t express a trailing call on its own, though, because nothing is scheduled to happen when the window closes.

Confusing throttle with debounce

If a continuous burst of calls produces exactly one call at the end, you’ve written a debounce. Throttle should produce a steady drip: one call, then another a window later, for as long as the calls keep coming.

Letting cancel invoke the pending call

Cancel closes the window and discards what was pending. It’s flush on a debounce that runs the pending call, and mixing the two up fails the second medium test.

The four combinations are worth rehearsing. Leading on and trailing off is the default drip. Leading off and trailing on delays everything by one window. Both on gives a call at each end of the window. Both off means the function never runs, which is why lodash treats that as leading-only.

Follow up questions

What’s the difference between throttle and debounce?

Throttle rations: at most one call per window, so a continuous burst produces a steady drip. Debounce waits for quiet: the call happens once the burst stops. Use throttle for scroll, resize and mousemove, where you want regular updates during the activity, and debounce for typeahead search, where you only care about the final state.

When would you use requestAnimationFrame instead of throttle?

For anything that paints. A throttle at 16ms is guessing at the frame rate; requestAnimationFrame fires exactly once before the next repaint, on whatever the display is actually doing. For scroll handlers that move elements, rAF is the better tool. Keep throttle for work that’s not visual, such as firing analytics or network calls.

Should throttle use setTimeout or Date.now?

Either works for the basic leading-edge case. A timer is the better choice once trailing calls are involved, because something has to be scheduled to fire when the window closes. A timestamp comparison alone can never produce a trailing call.

How do I throttle a scroll handler in React?

Create the throttled function once with useMemo or useRef so it survives re-renders, attach it in an effect, and call cancel() in the cleanup. A throttled function recreated on every render has a fresh window each time and throttles nothing.

Practise this with the tests running

Practice Pad runs these 9 cases against your own solution, in a real editor, on your Mac. Twenty-four questions, and an optional AI panel that explains why a test failed.

Download for macOS Free. Requires macOS 12 or later on Apple Silicon.

See all 24 questions