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.
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.
| Time | Action | Callback |
|---|---|---|
| 0 ms | Call with A | Run A immediately; open a window. |
| 40 ms | Call with B | Hold B for the trailing edge. |
| 90 ms | Call with C | Replace pending B with C. |
| 100 ms | Window closes | Run C; open the next window. |
| 200 ms | No more calls | Close 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.
✓ 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]]);✓ 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);✓ 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.
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.
Related interview questions
- JavaScript debounce interview question: solution and tests — Collapse a burst of calls into one, after the calls stop.
- Implement memoize in JavaScript — Cache results by argument, including undefined ones.
- Limit promise concurrency in JavaScript — Map with async, without launching everything at once.
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.