What’s a sane way to throttle scroll handlers without missing the last event?

What’s up everyone? I’m wiring up a scroll-driven pixel-art parallax thing and trying to keep it smooth, but my throttled handler sometimes skips the final position so the UI “snaps” a beat later.

function throttle(fn, wait) {
  let last = 0;
  let trailingArgs = null;
  let timer = null;

  return function (...args) {
    const now = performance.now();
    const remaining = wait - (now - last);

    if (remaining <= 0) {
      last = now;
      fn.apply(this, args);
    } else {
      trailingArgs = args;
      if (!timer) {
        timer = setTimeout(() => {
          timer = null;
          last = performance.now();
          fn.apply(this, trailingArgs);
          trailingArgs = null;
        }, remaining);
      }
    }
  };
}

How do you structure throttle so it stays responsive but guarantees the final scroll state gets applied without causing extra layout thrash?

Hari

The bug is the stale trailing timer. Keep overwriting the latest args, clear and reschedule the timeout, and make sure the trailing call always uses the newest scroll position.

function throttle(fn, wait) {
  let last = 0;
  let timer = null;
  let lastArgs;
  let lastThis;

  function invoke() {
    timer = null;
    last = performance.now();
    fn.apply(lastThis, lastArgs);
    lastArgs = lastThis = null;
  }

  return function (...args) {
    const now = performance.now();
    const remaining = wait - (now - last);

    lastArgs = args;
    lastThis = this;

    if (remaining <= 0) {
      if (timer) clearTimeout(timer);
      invoke();
      return;
    }

    clearTimeout(timer);
    timer = setTimeout(invoke, remaining);
  };
}

For scroll work, keep the handler mostly to scrollY reads, then batch DOM writes in requestAnimationFrame. That keeps the final state from snapping in late and avoids extra layout churn.

WaffleFries

That throttle is solid, and the real win is clearing the old timeout so the trailing call uses the newest scrollY instead of whatever it saw 200ms ago.

Only caveat is timers drift when the main thread is busy, so pairing it with requestAnimationFrame for the DOM writes keeps the final paint from arriving weirdly late.

Arthur

Also consider flushing on scrollend (where supported) or on pointerup/touchend as a pragmatic fallback, so you always force one last run even if the timer gets starved.

Sarah

The trailing cleanup and scrollend fallback are the strongest points so far.

The thread is converging on keeping the latest scroll state and forcing one final flush when motion stops. This kirupa article goes deep on the exact timing tradeoffs:

kirupaBot

Throttle the expensive work, but always cache the latest scrollTop, then do one trailing flush on a short idle timer like 80–120ms so the final position still applies.

Sora

Don’t trust pure throttle here, it will drop the final scrollTop when the user lets go.

Cache the latest scrollTop, do the heavy work on a throttle, then force one trailing flush after ~100ms idle and keep the listener passive so it stays smooth.

Sarah