Throttle Implementation
Implement throttle in JavaScript — leading vs trailing edges, cancel, comparison with debounce, React cleanup, and interview-ready code.
- javascript
- throttle
- performance
- debounce
- interview
Scroll, mousemove, and resize can fire dozens of times per second. If every event reads layout, writes DOM, or hits analytics, the main thread stutters.
Throttle guarantees the wrapped function runs at most once per wait interval while events keep arriving. Debounce waits for a pause in events, then runs once. Throttle samples a stream; debounce waits for quiet.
Interviewers ask for both to test closures, timers, and this forwarding — same family as debounce.
Spec (what “correct” means)
Given throttle(fn, wait):
- While calls continue,
fnruns at a controlled cadence — roughly once perwaitms. - Preserve last
thisand arguments for scheduled invocations. - Decide leading (run immediately on first call in a window) and trailing (run once more after the window with the latest args).
- Optional:
cancel()to clear a pending trailing call.
Default product feel for scroll position UI: leading + trailing so the first and last positions both update.
Minimal leading throttle
function throttle(fn, wait) {
let last = 0;
return function throttled(...args) {
const now = Date.now();
if (now - last >= wait) {
last = now;
return fn.apply(this, args);
}
};
}
const onScroll = throttle(() => {
console.log(window.scrollY);
}, 100);
window.addEventListener('scroll', onScroll, { passive: true });
Behavior: first scroll in a quiet period runs immediately; subsequent scrolls inside wait are dropped. No trailing call — if the user stops scrolling, the last dropped event never runs. Fine for “sample often enough,” weak when the final value must land.
Leading + trailing (the useful default)
function throttle(fn, wait, { leading = true, trailing = true } = {}) {
let last = 0;
let timerId = null;
let lastArgs;
let lastThis;
function invoke(time) {
last = time;
const args = lastArgs;
const ctx = lastThis;
lastArgs = lastThis = undefined;
return fn.apply(ctx, args);
}
function throttled(...args) {
const now = Date.now();
if (!last && !leading) {
// first call only schedules trailing
last = now;
}
const remaining = wait - (now - last);
lastArgs = args;
lastThis = this;
if (remaining <= 0 || remaining > wait) {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
invoke(now);
} else if (!timerId && trailing) {
timerId = setTimeout(() => {
timerId = null;
// if leading is false, last might need reset — common lodash-style:
invoke(leading === false ? Date.now() : Date.now());
}, remaining);
}
}
throttled.cancel = () => {
if (timerId) clearTimeout(timerId);
timerId = null;
last = 0;
lastArgs = lastThis = undefined;
};
return throttled;
}
Trace a burst of events every 10ms with wait = 100:
- t=0: leading invoke
- t=10…90: only update
lastArgs - t=100: either leading slot opens again or trailing timer fires with latest args
That “latest args” detail is the difference between a correct throttle and a buggy one that freezes the first event’s payload.
Timestamp vs timer-only designs
| Style | Idea | Tradeoff |
|---|---|---|
Timestamp (Date.now) |
Compare elapsed since last invoke | Simple; clock changes are rare noise |
| Timer only | setTimeout chains |
Easy trailing; must track pending carefully |
| Hybrid (above) | Timestamp + trailing timeout | Matches Lodash-style behavior people expect |
performance.now() is monotonic and slightly nicer for intervals; Date.now() is fine for UI throttling interviews.
Throttle vs debounce (pick deliberately)
// Debounce mental model: "run after things calm down"
// Throttle mental model: "run while things are busy, but not too often"
| Scenario | Prefer |
|---|---|
| Search-as-you-type → network | Debounce |
| Autocomplete after pause | Debounce |
| Scroll-linked progress bar | Throttle |
| mousemove drawing sample | Throttle |
| Window resize reflow | Either; throttle often feels snappier mid-drag |
| Button double-submit guard | Debounce leading or a disabled flag — not scroll-style throttle |
Full debounce treatment: Debounce Implementation.
requestAnimationFrame throttle
For visual work tied to paint, rAF is often better than setTimeout(…, 16):
function throttleRaf(fn) {
let scheduled = false;
let lastArgs;
let lastThis;
return function throttled(...args) {
lastArgs = args;
lastThis = this;
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
fn.apply(lastThis, lastArgs);
});
};
}
Runs at most once per frame. No wall-clock wait. Pair with passive scroll listeners. See requestAnimationFrame when that article is in your reading path.
React usage and cleanup
function ScrollProgress() {
const [pct, setPct] = useState(0);
const throttledRef = useRef(
throttle(() => {
const el = document.documentElement;
const max = el.scrollHeight - el.clientHeight;
setPct(max > 0 ? Math.round((el.scrollTop / max) * 100) : 0);
}, 100)
);
useEffect(() => {
const handler = throttledRef.current;
window.addEventListener('scroll', handler, { passive: true });
return () => {
window.removeEventListener('scroll', handler);
handler.cancel?.();
};
}, []);
return <div role="progressbar" aria-valuenow={pct}>{pct}%</div>;
}
Create the throttled function once (ref or module scope). A new throttle every render resets last and leaks timers. Cancel on unmount so a trailing call doesn’t setState on an unmounted tree — same discipline as useEffect Fundamentals.
TypeScript sketch
type Throttled<T extends (...args: any[]) => any> = ((
...args: Parameters<T>
) => ReturnType<T> | undefined) & { cancel: () => void };
function throttle<T extends (...args: any[]) => any>(
fn: T,
wait: number
): Throttled<T> {
let last = 0;
let timerId: ReturnType<typeof setTimeout> | null = null;
let lastArgs: Parameters<T> | undefined;
let lastThis: ThisParameterType<T>;
const throttled = function (this: ThisParameterType<T>, ...args: Parameters<T>) {
const now = Date.now();
lastArgs = args;
lastThis = this;
const remaining = wait - (now - last);
if (remaining <= 0) {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
last = now;
return fn.apply(lastThis, lastArgs);
}
if (!timerId) {
timerId = setTimeout(() => {
last = Date.now();
timerId = null;
if (lastArgs) fn.apply(lastThis, lastArgs);
}, remaining);
}
} as Throttled<T>;
throttled.cancel = () => {
if (timerId) clearTimeout(timerId);
timerId = null;
last = 0;
lastArgs = undefined;
};
return throttled;
}
Footguns
- No trailing edge — UI stuck one event behind when motion stops.
- Storing only the first args — must overwrite
lastArgsevery call. - Losing
this— usefunction+apply, not an arrow wrapper that freezes wrongthis. - Recreating throttle each render — identity and timer state reset.
wait = 0still async when trailing usessetTimeout— not a sync call.- Forgetting
{ passive: true }on scroll — main-thread jank unrelated to throttle logic. - Confusing with debounce in the interview intro — lead with the table.
Timers are macrotasks; dense trailing schedules still go through the event loop.
Interview angle
Prompt: “Implement throttle.”
Strong path:
- Define cadence vs debounce in one sentence.
- Write leading-only with
lasttimestamp (~8 lines). - Add trailing
setTimeout+lastArgs. - Add
cancel. - Mention React unmount and passive scroll.
Complexity: O(1) per event; cost is the underlying fn and timer scheduling.
Common fail: trailing timer closes over stale args from the first call in the window — always reassign lastArgs.
Related on this site
- Debounce Implementation — pause-based cousin
- Closures —
last/timerIdpersistence - this Binding Rules / call, apply, bind — why
applyappears - The Event Loop — timer tasks
- useEffect Fundamentals — cancel on unmount
- Event Delegation — high-frequency DOM patterns
Further reading
- MDN: setTimeout
- MDN: scroll event
- Lodash _.throttle — reference behavior for leading/trailing
- MDN: addEventListener passive
Related guides
- Debounce ImplementationImplement debounce in JavaScript — trailing vs leading, cancel/flush, TypeScript typing, and when to throttle instead.
- DocumentFragmentBuild subtrees off-DOM with DocumentFragment — one insert, fewer reflows, and how it differs from a wrapper div.
- Garbage Collection Mental ModelReachability, mark-and-sweep, retained closures and DOM — a practical GC model for frontend engineers debugging memory.
- IntersectionObserverObserve element visibility asynchronously — lazy images, infinite scroll, ad viewability, without scroll listener jank.
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.