ESC

Type to search the knowledge base.

Throttle Implementation

Implement throttle in JavaScript — leading vs trailing edges, cancel, comparison with debounce, React cleanup, and interview-ready code.

intermediate6 min read
  • 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):

  1. While calls continue, fn runs at a controlled cadence — roughly once per wait ms.
  2. Preserve last this and arguments for scheduled invocations.
  3. Decide leading (run immediately on first call in a window) and trailing (run once more after the window with the latest args).
  4. 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

  1. No trailing edge — UI stuck one event behind when motion stops.
  2. Storing only the first args — must overwrite lastArgs every call.
  3. Losing this — use function + apply, not an arrow wrapper that freezes wrong this.
  4. Recreating throttle each render — identity and timer state reset.
  5. wait = 0 still async when trailing uses setTimeout — not a sync call.
  6. Forgetting { passive: true } on scroll — main-thread jank unrelated to throttle logic.
  7. 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:

  1. Define cadence vs debounce in one sentence.
  2. Write leading-only with last timestamp (~8 lines).
  3. Add trailing setTimeout + lastArgs.
  4. Add cancel.
  5. 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.

Further reading

Related guides