InterviewsVector
beginnerVery common7 min read · Updated Jul 18, 2026

Implement Throttle (Leading and Trailing Done Right)

Implement throttle in JavaScript with timestamps, a correct trailing edge, leading/trailing options and cancel — including the stale-closure bug in the snippet everyone copies.


The problem

Implement throttle(fn, wait): during a stream of calls, run fn at most once per wait ms — first call immediately (leading edge), and after the stream pauses, one final call with the latest arguments (trailing edge).

Throttle guarantees a rate during activity; debounce waits for activity to end. Scroll handlers are the canonical case: with debounce you'd get nothing during a 10-second scroll; with throttle you get a steady tick. That distinction — and a trailing edge that actually works — is what this question grades.

Fair warning: the most widely copied "advanced throttle" snippet on the internet has a broken trailing edge. It's dissected below, because interviewers paste it deliberately.

Implementation

Timestamps for the leading edge, one timer for the trailing edge:

function throttle(fn, wait, { leading = true, trailing = true } = {}) {
  let lastInvoke = 0;      // when fn last actually ran (0 = never)
  let timerId = null;      // pending trailing-edge timer
  let pendingArgs = null;
  let pendingThis = null;
 
  const invoke = () => {
    lastInvoke = Date.now(); // read the clock AT INVOKE TIME — see the broken version
    timerId = null;
    fn.apply(pendingThis, pendingArgs);
    pendingArgs = pendingThis = null;
  };
 
  function throttled(...args) {
    const now = Date.now();
    let remaining = wait - (now - lastInvoke);
 
    // leading disabled: a fresh burst starts a full window instead of firing now
    if (remaining <= 0 && !leading) {
      lastInvoke = now;
      remaining = wait;
    }
 
    pendingArgs = args;
    pendingThis = this;
 
    if (remaining <= 0) {
      clearTimeout(timerId);
      invoke();                                   // leading edge: window expired → fire now
    } else if (trailing && timerId === null) {
      timerId = setTimeout(invoke, remaining);    // trailing edge: fire when window closes
    }
    // else: mid-window with a timer already set — just updated pendingArgs;
    // the trailing fire will use the LATEST arguments
  }
 
  throttled.cancel = () => {
    clearTimeout(timerId);
    timerId = null;
    lastInvoke = 0;
    pendingArgs = pendingThis = null;
  };
 
  return throttled;
}

Dry run (wait = 1000, scroll events every 100ms for 2.5s)

t=0     call → lastInvoke=0 means "long ago" → INVOKE (leading)
t=100…900   calls → remaining>0 → first one sets trailing timer for t=1000,
            each updates pendingArgs to the freshest event
t=1000  timer fires → INVOKE with t=900's args; lastInvoke=1000
t=1100…1900 calls → new trailing timer for t=2000
t=2000  INVOKE (args from t=1900); stream ends at t=2500
t=2500  last call → timer set for t=3000
t=3000  INVOKE — the trailing call that reports the FINAL scroll position

Steady once-per-second during the stream, plus the final-position call. That last invoke matters in practice: without a trailing edge, whatever you're syncing to scroll position stops one window short of where the user actually ended up.

The broken version everyone copies

// ❌ the snippet on half the blogs of the internet
function throttle(func, limit) {
  let lastFunc, lastRan;
  return function (...args) {
    const now = Date.now();                 // captured ONCE, at call time
    if (!lastRan) {
      func.apply(this, args);
      lastRan = now;
    } else {
      clearTimeout(lastFunc);
      lastFunc = setTimeout(() => {
        if (now - lastRan >= limit) {       // ← stale `now` from BEFORE the wait
          func.apply(this, args);
          lastRan = now;
        }
      }, limit - (now - lastRan));
    }
  };
}

Follow the values: we're in the else branch because now - lastRan < limit. The timeout fires later — but the condition re-checks the same frozen now, so it's still < limit, still false, and the trailing call never runs (or runs erratically when overlapping calls happen to refresh the closure). It's a stale-closure bug wearing a throttle costume — which is exactly why interviewers hand it to you as a "here's some code, what's wrong with it" question. The fix is the timestamp discipline in the main implementation: read the clock inside the timer callback, at invoke time.

Two more defects in that snippet worth naming in review: with leading: false-style needs it can't comply at all, and options = { leading: true, trailing: true } as a default parameter (in the common variant) means passing { leading: false } silently discards trailing — destructuring with per-key defaults (as in the main version) avoids it.

Edge cases interviewers probe

  • Trailing uses latest args — every mid-window call overwrites pendingArgs; the trailing invoke reports the freshest state. Firing with the first call's args is a subtle correctness bug (your UI syncs to a stale scroll position).
  • leading: false, trailing: false — a function that can never fire; degenerate but should be known, not discovered.
  • Burst shorter than wait — one leading call plus one trailing call; if the burst was a single call, no trailing duplicate (nulled pendingArgs guards it — same discipline as debounce's double-fire guard).
  • Clock choiceDate.now() jumps with system clock changes; performance.now() is monotonic. For UI throttling it rarely matters; knowing the difference does.
  • rAF as the throttle — for rendering work, requestAnimationFrame is a frame-rate throttle with better scheduling than any timer; "for paint-synced work I'd use rAF instead" is a strong aside (see custom setTimeout for the rAF/timer relationship).

Common mistakes

  • The stale-now trailing bug (above) — memorized from a blog, reproduced under pressure, caught in one question.
  • The boolean-flag version (inThrottle) presented as complete — it silently drops the trailing edge, losing the final state.
  • Whole-object option defaults that vanish when partially overridden.
  • No cancel — unmounting components need to kill the pending trailing call (same React wiring as debounce).
  • Testing with real setTimeout sleeps instead of fake timers.

Test yourself

Try it yourself

Console

Press Run to execute this snippet in a sandboxed worker.

Debounce vs throttle (the guaranteed follow-up)

DebounceThrottle
Modelreset the timer on every callfixed-rate windows
During continuous activitysilentsteady invocations
After activity stopsone callone trailing call
Usetyping, autosavescroll, mousemove, resize, drag

The unifying insight interviewers reward: throttle ≈ debounce with maxWait = wait — lodash implements it exactly that way. If you've built debounce with maxWait, you've built throttle.

Follow-up questions

  • "Implement throttle using your debounce." — pass maxWait: wait through; explain why the guarantee becomes a rate.
  • "Throttle by frame instead of milliseconds." — rAF-based: schedule invoke on the next frame if not already scheduled; auto-adapts to display refresh and pauses in background tabs.
  • "What happens in background tabs?" — browsers clamp timers (≥1s, or heavier); your throttle's effective rate changes. For correctness-critical work, listen to visibilitychange and flush.
  • "Rate-limit API calls with this?" — client-side throttle smooths bursts but doesn't enforce a server contract (multiple tabs, retries); a server-acknowledged limiter or token bucket does — the batching and pool questions pick up that thread.
  • "How would you test the trailing edge specifically?" — fake timers: call at t=0 and t=100, advance to wait, assert exactly two calls and that the second received t=100's arguments — the assertion that catches the broken snippet above.

  • Rebuild setTimeout on top of requestAnimationFrame to show you understand timer scheduling and drift.

  • Debounce

    beginner

    Implement debounce from scratch, with leading/trailing options and the classic search-input use case.

  • Custom setInterval

    intermediate

    Implement setInterval with setTimeout recursion — and fix the drift problems of the native version.

  • clearAllTimers

    intermediate

    Track and clear every active timeout and interval — a utility question about monkey-patching globals safely.