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.
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.
✓ 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.
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.