DEV Community

Cover image for You're Debouncing Scroll Wrong. requestAnimationFrame Fixes It.
Parsa J
Parsa J

Posted on Originally published at bestpractic.org

You're Debouncing Scroll Wrong. requestAnimationFrame Fixes It.

Our sticky header started lagging behind the scroll by about half a second. Not broken — just late, like it was watching the page over a bad video call. Scroll up fast and the header's drop shadow would still be fading in a beat after you'd already stopped.

The code looked right. It was debounced, just like the resize handler two files over, just like every "optimize this event listener" article says to do. Debouncing a scroll handler is the move everyone learns for performance reasons. Nobody mentions it also breaks the thing you were trying to show the user in real time.

The handler that was "optimized" into being wrong

Here's roughly what was wrapping the scroll listener:

function debounce(fn, wait) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), wait);
  };
}

window.addEventListener("scroll", debounce(updateHeader, 150));
Enter fullscreen mode Exit fullscreen mode

debounce resets its timer on every single scroll event and only fires once the events stop coming — once you stop scrolling. That's exactly what you want for a resize handler recalculating a layout: you don't care about every intermediate width, just the final one, and recalculating 40 times mid-drag is wasted work.

A sticky header isn't that. The user wants to see the shadow and background opacity change as they scroll, not 150ms after they've already arrived. Debounce doesn't throttle the rate of updates — it delays the update until the interaction is over. For anything the user is supposed to watch happen live, that's backwards.

Quick gut check before you keep reading: have you ever debounced a scroll or mousemove handler because that's just "what you do" for frequent events, without asking whether debounce or throttle was the one you actually needed?

Throttling fixes the lag. It doesn't fix the timing.

The standard next move is to swap debounce for throttle — run the handler on a fixed interval instead of waiting for silence:

function throttle(fn, wait) {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last >= wait) {
      last = now;
      fn(...args);
    }
  };
}

window.addEventListener("scroll", throttle(updateHeader, 16));
Enter fullscreen mode Exit fullscreen mode

This is closer — the header updates during the scroll now. But look at that 16. Where did it come from? It's "roughly one frame at 60Hz," typed in because that's the number every blog post uses. It assumes the display is 60Hz. A lot of phones and newer monitors run at 90Hz, 120Hz, even 144Hz — on those, 16 isn't "once per frame" anymore, it's "slower than the screen can actually paint," and the update looks subtly behind even though your throttle is technically running.

You're also still paying for a Date.now() call and a comparison on every scroll event, even the ones you throw away. The scroll event itself can fire dozens of times between two paints. All of that extra firing is wasted — the browser is only going to show the user a new frame once, no matter how many times you ran your handler before it.

The browser already has a clock for this

Here's the part that's easy to miss: the browser knows exactly when it's about to paint the next frame. You don't have to estimate it with a setTimeout interval — you can ask for it directly with requestAnimationFrame.

let ticking = false;

function onScroll() {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    updateHeader();
    ticking = false;
  });
}

window.addEventListener("scroll", onScroll, { passive: true });
Enter fullscreen mode Exit fullscreen mode

The ticking flag is the whole trick. scroll can fire many times before the next paint; the flag makes sure only the first one of those schedules an update, and every scroll event after it until the next frame is a no-op — a single boolean check, nothing more. When the browser is finally ready to paint, it calls your callback once, you update the header with the latest scroll position, and you flip the flag back so the next scroll event can schedule the next frame.

No guessed interval. No wasted setTimeout calls that get cleared before they fire. No lag after the user stops, because there's no waiting-for-silence step at all — the update rides along with the very next frame the browser was already going to paint, on a 60Hz screen or a 120Hz one.

{ passive: true } is still worth keeping on the listener, but be precise about why: scroll itself isn't cancelable, so preventDefault() inside a scroll handler does nothing either way — passive or not. The option earns its keep on events that are cancelable, like touchmove and wheel, where it tells the browser you'll never call preventDefault() so it can start scrolling immediately instead of waiting to find out. Add it to the scroll listener anyway as the correct default for any handler that never cancels anything.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

When you still want debounce

None of this makes debounce wrong in general — it just means the choice depends on what the user is waiting for. A search-as-you-type box should wait for the person to stop typing. A "save draft" call should wait for a pause in edits. A window-resize recalculation that's expensive should wait until the user's done dragging the edge of the browser.

The question that decides it: does the user need to see this update during the interaction, or only after it settles? Live visual feedback — scroll position, drag position, pointer tracking — wants a per-frame throttle, and requestAnimationFrame is the native, interval-free way to get one. Anything that's expensive and only matters at the end wants debounce.

The header stopped lagging the day we swapped the setTimeout number for a question the browser could just answer itself.

What's debounced in your codebase right now that's actually supposed to be live? Go check your scroll and mousemove handlers — I'd bet at least one of them is waiting for silence when it should be riding the next frame.

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (2)

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

debouncing scroll with rAF instead of timers, clean fix.

Collapse
 
kenyalba profile image
Keny Alba •

The sampling case deserves the same treatment: if you record scroll position for replay or analytics rather than update visuals, read scrollY inside the rAF callback too. Values read in a raw scroll handler can be intermediate states that never got composited - you'd log scroll positions the user never actually saw on screen.

For the "wait until it settles" pattern there's also a native option now: the scrollend event (Baseline 2025) fires when scrolling stops, no debounce timer needed. Debounce still earns its place for expensive work like save-draft calls, but "the user stopped scrolling" is a built-in these days.