Throttle Function in JavaScript
Throttling caps how often a function can fire to at most once per interval, which is the right tool for scroll, mousemove, and analytics beacons. This snippet contrasts throttle against debounce, then walks from a leading-edge timestamp gate to a `setTimeout`-driven version that includes a manual `cancel`. Pick the variant that matches whether the very first call should fire immediately.
802 views
17
function throttle(fn, wait) {
let lastCall = 0;
return function throttled(...args) {
const now = Date.now();
if (now - lastCall >= wait) {
lastCall = now;
fn.apply(this, args);
}
};
}
const report = throttle((x) => console.log('scroll:', x), 50);
report(0);
report(10); // dropped (too soon)
report(20); // dropped
setTimeout(() => report(100), 60); // fires
// Output (immediate): scroll: 0
// After ~60ms: scroll: 100The simplest throttle compares the current timestamp against the last accepted call. If at least wait milliseconds have passed, the new call goes through and the timestamp resets; otherwise it is silently dropped. This pattern fires on the leading edge (the very first call always wins) which feels responsive for analytics beacons and event logging. The downside is that if a burst of calls all land inside one window, only the first sees the latest arguments, so the trailing data is lost. Pair this with a debounce on the trailing edge if both are needed (see the Hard accordion js-throttle-leading-trailing).
function throttleTimer(fn, wait) {
let scheduled = false;
let lastArgs = null;
let lastThis = null;
return function throttled(...args) {
lastArgs = args;
lastThis = this;
if (scheduled) return;
scheduled = true;
setTimeout(() => {
scheduled = false;
fn.apply(lastThis, lastArgs);
lastArgs = null;
lastThis = null;
}, wait);
};
}
const tick = throttleTimer((n) => console.log('tick', n), 30);
tick(1);
tick(2);
tick(3);
// One log after ~30ms with the LATEST args:
// tick 3Switching to setTimeout gives you two useful properties. The fire is scheduled exactly wait milliseconds after the first call in a burst, so the gap between fires is constant and predictable. And because lastArgs is rewritten on every call, the deferred fire always uses the most recent arguments, which is what scroll-position trackers actually want. The cost is that the very first call no longer fires immediately; if you need leading-edge behaviour, set scheduled = false AND call fn once in the same branch the first time.
function throttleWithCancel(fn, wait) {
let timer = null;
let lastArgs = null;
function throttled(...args) {
lastArgs = args;
if (timer) return;
timer = setTimeout(() => {
timer = null;
fn.apply(null, lastArgs);
lastArgs = null;
}, wait);
}
throttled.cancel = () => {
if (timer) clearTimeout(timer);
timer = null;
lastArgs = null;
};
return throttled;
}
const track = throttleWithCancel((p) => console.log('beacon', p), 100);
track({ x: 1 });
track({ x: 2 });
track.cancel(); // drop the queued fire
console.log('after cancel');Cancellation is the same story as for debounce: a route change or component unmount needs to drop the pending fire so the analytics beacon does not run with stale state. The implementation closes over timer and lastArgs and clears both when cancel is called. This pattern composes well with the React effect cleanup contract: return throttled.cancel from your useEffect and the throttled handler will not fire after the component unmounts. Use this version inside framework-managed lifecycles where teardown is not optional.
