JavaScript throttle interview question: solution and tests

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

Implement throttle(callback, wait, options) without a library. Start with leading-only calls, preserve the calling this and arguments, add cancel(), then support a trailing call with the latest suppressed arguments.

Clarify the options first: this exercise defaults to leading: true and trailing: false. Cancel discards pending work and closes the window. A later call can start a fresh window. The wrapper does not return or cache the callback result.

Work through the JavaScript debounce interview question alongside this one to explain the difference. If the limit is the number of running requests rather than calls per time window, see mapping async work with a concurrency limit.

Try implementing it before reading the solution. The JavaScript interview practice editor lets you write your answer and run the question’s tests.

Trace the calls before writing the code

Use a 100 ms wait with leading: true and trailing: true. Call the same throttled function with A at 0 ms, B at 40 ms and C at 90 ms.

Expected callback timing for this interview exercise
TimeActionCallback
0 msCall with ARun A immediately; open a window.
40 msCall with BHold B for the trailing edge.
90 msCall with CReplace pending B with C.
100 msWindow closesRun C; open the next window.
200 msNo more callsClose the window without another callback.

With the default trailing: false, only A runs in this example. With trailing enabled, cancel at 70 ms discards B and closes the window, so a new call at 90 ms can run immediately. Times assume an idle event loop.

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 for this exercise. Lodash enables both by default, so clarify the requested behaviour before coding.

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.

Agree on the contract before coding. This exercise defaults to leading calls only. With trailing enabled, a suppressed call can run when the window closes; a single leading call does not also produce a trailing call. With both options false, this implementation never invokes the callback. Lodash throttle enables both leading and trailing by default and also provides flush and result-returning behaviour. This is an interview exercise, not a drop-in replacement.

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, in your browser or on your desktop. Twenty-four questions, and an AI panel that explains why a test failed.

See all 24 questions