Debounce a scroll handler without dropping the last call

The problem

Scroll events arrive dozens of times per second while the user drags, but the readout at the end of the handler only means something once scrolling settles. Running the full body mid-burst wastes work, and an over-eager skip would leave the final position never reported. Assumes a modern browser and a handler that is safe to delay by a fixed quiet period.

Short answer

Give each incoming event a fresh setTimeout and clear the pending one, so the callback runs exactly once a fixed delay after the last event, carrying that last event's data rather than nothing.

While a finger or a wheel moves, scroll arrives in bursts, and only the tail of each burst carries the position worth acting on. Running the handler body on every event recomputes the same answer dozens of times a second; skipping calls carelessly throws away the one that mattered. A debounce timer turns each burst into exactly one trailing call.

Language: JavaScript
const status = document.querySelector('#scroll-status');

function debounced(delayMs, callback) {
  let timerId = null;
  return (...args) => {
    if (timerId !== null) {
      clearTimeout(timerId);
    }
    timerId = setTimeout(() => {
      timerId = null;
      callback(...args);
    }, delayMs);
  };
}

const reportPosition = debounced(150, () => {
  const remaining = Math.max(
    document.documentElement.scrollHeight - window.scrollY - window.innerHeight,
    0
  );
  status.textContent = remaining + 'px left to scroll';
});

window.addEventListener('scroll', reportPosition, { passive: true });

Explanation

Each arriving event clears the timer the previous event scheduled and schedules a new one. While events keep coming faster than delayMs, the callback is permanently deferred; the first quiet gap lets the last scheduled timer complete. That final timer was set by the last event and closes over the last event’s arguments, which is exactly why the trailing shape keeps what a naive leading-only skip would drop: the settled scroll position. Pending state is tracked against an explicit null sentinel rather than the handle’s truthiness, because handle values are a host detail — browsers hand back an integer and other JavaScript runtimes hand back an object.

The trade-off is delay for accuracy. 150ms was chosen longer than the typical gap between consecutive scroll events in one continuous drag and shorter than a deliberate pause, so the user perceives the readout as settling with them. A leading-edge variant — fire immediately, then ignore — suits handlers where the first event matters most, like a button guard; position readouts want the opposite because intermediate positions are noise.

The boundary conditions are timing edges rather than code paths. If the user scrolls continuously with gaps always shorter than the delay, nothing runs until they stop, so work that must appear mid-scroll belongs behind a throttle instead. And because the pending call lives only in this page’s timer queue, a navigation or tab discard during the burst drops it silently; treat the trailing call as best-effort reporting, not as a place to persist state.

Next steps

Debounce answers how often a handler runs. When the question becomes whether an element is on screen at all, an IntersectionObserver reports viewport entry without any timer arithmetic — the reveal example applies that same settle-once behaviour to an animation.

Parameters

Named inputs for the code above
NameTypeRequiredDefaultDescription
delayMsnumberYes—Quiet period in milliseconds after the last event before the callback runs.
callbackfunctionYes—Handler invoked once with the arguments of the most recent event in the burst.

Usage notes

  • A passive listener is the right host for this wrapper because the debounced function only reads position and never cancels scrolling.

Caveats

  • A pending debounced call is lost if the page is discarded or navigates away inside the quiet period; nothing flushes it for you.
  • Choose throttle instead of debounce when the handler must report at a steady rhythm during the burst rather than once after it.

Follow-up

Related examples

Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.

References