A teammate scrolled the product list on their phone and said it felt "sticky" — not broken, just a little behind, like the page was thinking about it before it moved. On a laptop trackpad it was perfectly smooth. Same code, same list, same 12-line scroll handler that did almost nothing: check scrollY, toggle a "back to top" button.
Twelve lines. Nothing in it that should cost more than a millisecond. And yet, on a phone, every scroll felt like it had a half-beat of drag.
The fix that didn't fix it
The obvious read is "the handler is too slow," so that's where the first hour went:
list.addEventListener("scroll", () => {
backToTop.hidden = list.scrollTop < 400;
});
Wait — not even scroll. The actual listener was on touchmove, added so the button could react mid-gesture instead of waiting for the scroll to settle:
list.addEventListener("touchmove", () => {
backToTop.hidden = list.scrollTop < 400;
});
That one line is the whole bug, and nothing in it looks dangerous. We debounced it. We memoized the scroll position. We moved the class toggle behind requestAnimationFrame. Chrome DevTools' performance panel kept showing the same thing after every change: a yellow "Rendering" block sitting before the scroll could start, not after the handler ran.
Before you keep reading — what do you think the browser was actually waiting for?
It wasn't waiting for the handler to be fast. It was waiting to find out whether the handler would call preventDefault().
Why a touchmove listener can freeze a scroll that never gets cancelled
touchmove, touchstart, and wheel are special. Any listener for one of them is allowed to call event.preventDefault() and cancel the browser's default action — in this case, cancel the scroll itself, mid-gesture. A custom image-zoom gesture, a swipe-to-dismiss card, a "no, don't scroll past this point" lock — all of those work by listening to one of these events and vetoing the browser's plan.
The browser doesn't know in advance that your handler isn't one of those. So by default it plays it safe: run the handler first, check event.defaultPrevented, and only then move the page. Your handler reads scrollY and sets .hidden — completely harmless — but the browser can't see that from the outside. It has to ask.
That ask is synchronous, and it happens on the main thread. If the main thread is busy with literally anything else at that instant — a re-render, a timer, a layout recalculation — the scroll waits behind it. Debouncing the handler doesn't help, because the delay isn't the handler's duration. It's the fact that scrolling is gated on the handler returning at all, every single time it fires.
This is exactly what Lighthouse is flagging when it tells you a page "does not use passive listeners to improve scrolling performance." It isn't measuring your code's speed. It's measuring whether you've told the browser it's allowed to stop asking.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
The one-line fix
addEventListener takes a third argument beyond the usual boolean-for-capture — an options object with a passive flag:
list.addEventListener("touchmove", () => {
backToTop.hidden = list.scrollTop < 400;
}, { passive: true });
{ passive: true } is a promise: this listener will never call preventDefault(). The browser takes you at your word, stops waiting, and starts scrolling the instant the gesture begins — on the compositor thread, independent of whatever your handler is doing. The handler still runs, still takes however long it takes, but scrolling no longer holds its breath for the answer.
Break that promise and call preventDefault() inside a passive listener anyway, and nothing happens — the call is silently ignored. Chrome and Firefox log a console warning telling you so; Safari doesn't, so don't count on the console to catch the mistake for you. There's no partial credit. A listener is either allowed to cancel the gesture, or it isn't, and passive is you choosing which.
Try scrolling the list below with the toggle off, then on — same handler, same cost, very different feel.
"Isn't this already the default?"
Sort of, and that's the trap. A few browsers quietly mark some touch listeners passive on their own — but only in specific cases (certain root-level targets, specific event types), and the exact cases differ by engine and have changed over time as each one shipped its own optimization. Relying on an undocumented default that varies by browser and by which element you attached to is not a strategy. It's a bet.
The one-line fix works everywhere, on every engine, whether or not that engine happens to be feeling generous today:
list.addEventListener("touchmove", handler, { passive: true });
list.addEventListener("wheel", handler, { passive: true });
Add it to every touchstart, touchmove, or wheel listener where you genuinely never call preventDefault() — scroll-position trackers, infinite-scroll triggers, analytics pings, "show the nav on scroll up" toggles, if you've wired them to one of those three events. That's most of them. (A plain scroll listener doesn't need this at all — scroll events are never cancelable, so the browser was never waiting on them in the first place.) The only time you can't use { passive: true } is when the listener sometimes needs to cancel the gesture — a custom swipe handler, a drag-to-reorder list, a pinch-zoom lock. In that case, leaving it non-passive is correct, not an oversight — you're keeping the veto because you actually use it.
The list, scrolling again
Back-to-top button, 12 lines, one options object added. The teammate who first noticed the stutter scrolled the same list on the same phone and didn't say anything — which, for a performance fix, is exactly the review you want. Nobody writes a bug report about something that just works.
Go check one scroll handler in your own codebase right now — does it call preventDefault() anywhere? If the honest answer is no, it's missing one argument. What's the oldest scroll listener in your code that's never had { passive: true } added — and do you know whether it blocks anything?
🧠 Test yourself
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
📚 Read next
- Stop Guessing When Scrolling Stops — Use scrollend
- I Built Cross-Tab Logout. The storage Event Skipped One Tab.
- You Added
will-changeto Fix the Jank. You Made It Worse.
🚀 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:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)