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.

JavaScriptintermediate
14 min read

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

CodeCode
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

javascriptjavascript
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

javascriptjavascript
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

ImplementationLeading (first call)Trailing (last call)Behavior
Timer-basedFires immediatelyFires after intervalCaptures both first and last events
Timestamp-basedFires immediatelyDroppedSimpler, may miss the final state
With optionsConfigurableConfigurableFull control

Configurable Throttle

javascriptjavascript
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

javascriptjavascript
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

javascriptjavascript
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

javascriptjavascript
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:

javascriptjavascript
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

FeatureThrottleDebounce
When it firesAt regular intervals during activityAfter activity stops
Continuous eventsFires every N msNever fires while events continue
First eventFires immediately (leading edge)Delayed by full interval
Last eventMay fire trailing edgeAlways fires (trailing)
Scroll positionUse throttleAvoid (misses intermediate states)
Search inputAvoid (fires too often)Use debounce
Window resizeEither worksDebounce is more common
Button clickLeading throttleLeading debounce

Performance Impact

javascriptjavascript
// 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 IntervalCalls/Secondvs Raw (60fps)Use Case
16ms (rAF)~600% reductionSmooth visual animations
50ms~2067% reductionInteractive tracking
100ms~1083% reductionScroll progress, resize
250ms~493% reductionAnalytics, lazy loading
1000ms~198% reductionBackground updates

Common Pitfalls

Forgetting passive event listeners

javascriptjavascript
// 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

javascriptjavascript
const throttledResize = throttle(handleResize, 200);
window.addEventListener("resize", throttledResize);
 
// Cleanup
window.removeEventListener("resize", throttledResize);
throttledResize.cancel();
Rune AI

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
RunePowered by Rune AI

Frequently Asked Questions

When should I use throttle instead of debounce?

Use throttle when you need periodic updates during continuous activity (scroll position, mouse coordinates, progress tracking). Use debounce when you only care about the final value after activity stops (search input, form validation). See [debouncing in JavaScript a complete tutorial](/tutorials/programming-languages/javascript/debouncing-in-javascript-a-complete-tutorial) for the debounce pattern.

What is the ideal throttle interval for scroll events?

100-200ms for data-driven updates (progress bars, lazy loading). 16ms or `requestAnimationFrame` for visual animations (parallax, transforms). Lower values are smoother but use more CPU.

Does the `{ passive: true }` option affect throttling?

Not directly, but it is critical for scroll performance. Passive listeners tell the browser it can scroll immediately without waiting for your handler. Always use `{ passive: true }` for scroll and touch events.

Can I throttle async functions?

Yes, but be aware that overlapping calls may occur if the async function takes longer than the throttle interval. Add a guard flag (`isRunning`) to skip calls while a previous one is still in flight.

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.