Throttling in JavaScript: A Complete Tutorial
A complete tutorial on throttling in JavaScript. Covers how throttling works, building a throttle function from scratch, leading and trailing edge execution, timestamp-based vs timer-based implementations, real-world use cases like scroll tracking and mousemove handlers, throttle vs debounce comparison, and requestAnimationFrame as a visual throttle.
Throttling ensures a function executes at most once in a specified time interval, no matter how many times it is called. Unlike debouncing, which waits for calls to stop, throttling fires at regular intervals during continuous activity. This makes it ideal for scroll handlers, mousemove trackers, and resize listeners where you need periodic updates.
How Throttling Works
Scroll events: |||||||||||||||||||||||||||||||
Throttle (200ms): X-------X-------X-------X----X
Without throttle: 30+ calls per second
With throttle: 5 calls per second (every 200ms)
The function fires immediately on the first call, then ignores subsequent calls until the interval elapses.
Timer-Based Throttle
function throttle(fn, interval) {
let isThrottled = false;
let savedArgs = null;
let savedThis = null;
function wrapper(...args) {
if (isThrottled) {
// Save the latest call to execute after the interval
savedArgs = args;
savedThis = this;
return;
}
fn.apply(this, args);
isThrottled = true;
setTimeout(() => {
isThrottled = false;
if (savedArgs) {
wrapper.apply(savedThis, savedArgs);
savedArgs = null;
savedThis = null;
}
}, interval);
}
return wrapper;
}This implementation fires immediately (leading edge) and also fires once more after the interval with the most recent arguments (trailing edge).
Timestamp-Based Throttle
function throttle(fn, interval) {
let lastCallTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastCallTime >= interval) {
lastCallTime = now;
fn.apply(this, args);
}
};
}The timestamp approach is simpler but only fires on the leading edge. It drops the trailing call, which means the last event in a burst might be missed.
Leading vs Trailing Behavior
| Implementation | Leading (first call) | Trailing (last call) | Behavior |
|---|---|---|---|
| Timer-based | Fires immediately | Fires after interval | Captures both first and last events |
| Timestamp-based | Fires immediately | Dropped | Simpler, may miss the final state |
| With options | Configurable | Configurable | Full control |
Configurable Throttle
function throttle(fn, interval, options = {}) {
const leading = options.leading !== false; // default true
const trailing = options.trailing !== false; // default true
let lastCallTime = 0;
let timeoutId = null;
let lastArgs = null;
let lastThis = null;
function invoke() {
fn.apply(lastThis, lastArgs);
lastCallTime = Date.now();
lastArgs = null;
lastThis = null;
}
function throttled(...args) {
const now = Date.now();
const remaining = interval - (now - lastCallTime);
lastArgs = args;
lastThis = this;
if (remaining <= 0 || remaining > interval) {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (leading) {
invoke();
} else {
lastCallTime = now;
}
}
if (!timeoutId && trailing) {
timeoutId = setTimeout(() => {
timeoutId = null;
if (trailing) invoke();
}, remaining > 0 ? remaining : interval);
}
}
throttled.cancel = function () {
clearTimeout(timeoutId);
timeoutId = null;
lastCallTime = 0;
lastArgs = null;
lastThis = null;
};
return throttled;
}Real-World: Scroll Position Tracking
function updateScrollProgress() {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = Math.round((scrollTop / docHeight) * 100);
document.getElementById("progress-bar").style.width = `${progress}%`;
document.getElementById("progress-text").textContent = `${progress}%`;
}
const throttledScroll = throttle(updateScrollProgress, 100);
window.addEventListener("scroll", throttledScroll, { passive: true });See scroll event throttling in JavaScript full guide for advanced scroll patterns like infinite scroll and sticky headers.
Real-World: Mousemove Tracker
const tooltip = document.getElementById("tooltip");
function moveTooltip(event) {
tooltip.style.left = `${event.clientX + 10}px`;
tooltip.style.top = `${event.clientY + 10}px`;
}
// 60fps = ~16ms per frame; throttle to 16ms
const throttledMove = throttle(moveTooltip, 16);
document.addEventListener("mousemove", throttledMove);Real-World: API Call Rate Limiting
async function sendAnalytics(eventData) {
await fetch("/api/analytics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(eventData),
});
}
// Maximum 1 analytics call per 5 seconds
const throttledAnalytics = throttle(sendAnalytics, 5000);
document.addEventListener("click", (event) => {
throttledAnalytics({
type: "click",
target: event.target.tagName,
timestamp: Date.now(),
});
});See rate limiting in JavaScript complete tutorial for server-side rate limiting patterns.
requestAnimationFrame as Visual Throttle
For visual updates, requestAnimationFrame is the natural throttle:
function rafThrottle(fn) {
let frameId = null;
function throttled(...args) {
if (frameId) return;
frameId = requestAnimationFrame(() => {
fn.apply(this, args);
frameId = null;
});
}
throttled.cancel = () => {
if (frameId) {
cancelAnimationFrame(frameId);
frameId = null;
}
};
return throttled;
}
// Usage: smooth scroll-linked animation
const rafScroll = rafThrottle(() => {
const y = window.scrollY;
parallaxLayer.style.transform = `translateY(${y * 0.3}px)`;
});
window.addEventListener("scroll", rafScroll, { passive: true });Throttle vs Debounce
| Feature | Throttle | Debounce |
|---|---|---|
| When it fires | At regular intervals during activity | After activity stops |
| Continuous events | Fires every N ms | Never fires while events continue |
| First event | Fires immediately (leading edge) | Delayed by full interval |
| Last event | May fire trailing edge | Always fires (trailing) |
| Scroll position | Use throttle | Avoid (misses intermediate states) |
| Search input | Avoid (fires too often) | Use debounce |
| Window resize | Either works | Debounce is more common |
| Button click | Leading throttle | Leading debounce |
Performance Impact
// Measuring the effect of throttling
let callCount = 0;
function rawHandler() { callCount++; }
const throttled100 = throttle(rawHandler, 100);
const throttled250 = throttle(rawHandler, 250);
// Simulating 1 second of continuous scroll events at 60fps
// Raw: ~60 calls
// 100ms throttle: ~10 calls (83% reduction)
// 250ms throttle: ~4 calls (93% reduction)| Throttle Interval | Calls/Second | vs Raw (60fps) | Use Case |
|---|---|---|---|
| 16ms (rAF) | ~60 | 0% reduction | Smooth visual animations |
| 50ms | ~20 | 67% reduction | Interactive tracking |
| 100ms | ~10 | 83% reduction | Scroll progress, resize |
| 250ms | ~4 | 93% reduction | Analytics, lazy loading |
| 1000ms | ~1 | 98% reduction | Background updates |
Common Pitfalls
Forgetting passive event listeners
// WRONG: can cause scroll jank
window.addEventListener("scroll", throttledHandler);
// CORRECT: tells browser the handler will not call preventDefault()
window.addEventListener("scroll", throttledHandler, { passive: true });Not cleaning up throttled handlers
const throttledResize = throttle(handleResize, 200);
window.addEventListener("resize", throttledResize);
// Cleanup
window.removeEventListener("resize", throttledResize);
throttledResize.cancel();Rune AI
Key Insights
- Throttle fires during activity, debounce fires after it stops: Throttle gives periodic updates; debounce gives the final result
- Timer-based captures trailing calls: Saves the latest arguments and replays after the interval, ensuring the last state is not lost
- requestAnimationFrame is the visual throttle: Aligns execution with the browser repaint cycle (~16ms at 60fps) for smooth animations
- Always use passive event listeners: Add
{ passive: true }to scroll and touch handlers to prevent layout thrashing - 100-200ms is the standard scroll throttle: Low enough to feel responsive, high enough to save 80-90% of unnecessary function calls
Frequently Asked Questions
When should I use throttle instead of debounce?
What is the ideal throttle interval for scroll events?
Does the `{ passive: true }` option affect throttling?
Can I throttle async functions?
Conclusion
Throttling limits function execution to fixed intervals during continuous events. The timer-based approach captures both leading and trailing calls; the timestamp approach is simpler but drops the final call. Use requestAnimationFrame for visual updates, { passive: true } for scroll event listeners, and 100-250ms intervals for data-driven handlers. For the debounce counterpart, see debouncing in JavaScript a complete tutorial. For the event loop that schedules these timers, see the JS event loop architecture complete guide.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.