Passive Listeners
A touchstart or wheel listener can hold a scroll hostage until it has run. { passive: true } is a promise not to cancel — and browsers now assume it in places, which changes what your code does.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Why does adding an empty scroll-related listener make scrolling stutter, and what does { passive: true } actually promise?
Someone flicks a finger or spins a wheel to move down a page. Scrolling is the most frequent interaction on the web and the one where lag is felt most immediately.
Add a touchstart or wheel listener to track the gesture. The handler is tiny and returns immediately, so it cannot possibly affect performance.
The cost is not the handler body — it is the wait. Scrolling is driven by the compositor, off the main thread; a cancellable listener means the compositor must ask the main thread whether the scroll is allowed before moving a single pixel (Scroll and Input Latency).
- The cost is not the handler body — it is the wait. Scrolling is driven by the compositor, off the main thread; a cancellable listener means the compositor must ask the main thread whether the scroll is allowed before moving a single pixel (Scroll and Input Latency).
- If the main thread is busy — hydrating, running a framework render, parsing a chunk — the scroll waits for it. The handler that runs in microseconds waited behind a task that did not (Long Tasks).
- The listener does not have to do anything for this to happen. Registering a non-passive
touchstartat all is the signal; an empty function has the identical effect. - A listener registered on
windowordocumentcovers the whole page, so one library adding one non-passive handler can make every scroll in the product wait on the main thread. - Browsers have since made some of these passive by default, which means the same code that used to cancel a gesture now silently fails to — and the failure is a console warning at best (preventDefault vs stopPropagation).
- The reverse bug is just as real: code that genuinely needs to cancel a gesture and does not pass
{ passive: false }explicitly is now a no-op in engines that default to passive.
What is actually happening
In the browser, not in the framework.
addEventListener(type, fn, { passive: true })is a promise to the browser: this listener will not callpreventDefault(). Given that promise, the browser can start the scroll immediately and run your handler whenever it gets to it.- Without the promise,
touchstart,touchmove,wheelandmousewheelare potentially cancellable, so the compositor blocks the gesture until the main thread reports back — this is the whole mechanism, and it is a scheduling dependency, not a cost in the function itself. - Engines now default
touchstartandtouchmoveto passive when the listener is registered onwindow,document,document.documentElementordocument.body, and Chromium extends the same default towheelandmousewheel. Listeners on other elements are unaffected. - Calling
preventDefault()inside a passive listener does nothing. Chromium logs a console warning; the event'scancelableisfalse, so there is nothing to cancel (preventDefault vs stopPropagation). - The
scrollevent is a different thing entirely: it is not cancellable at all, it fires after the scroll has already happened, and marking it passive changes nothing about scheduling. - The declarative alternative is CSS
touch-action.touch-action: noneorpan-ytells the compositor before any event which gestures belong to your element, so no main-thread round trip is needed at all (Pointer Events).
What this makes the browser do
And which of it is avoidable.
- With a cancellable listener: hit-test, hand the event to the main thread, wait for the listener to run, then start scrolling. Every input in the gesture inherits that dependency.
- With a passive listener: start scrolling on the compositor immediately and run the listener independently. Scroll frames continue even if the main thread is fully occupied (The Frame Budget).
- With
touch-actionset: the decision is made from style, before the gesture, with no main-thread involvement at all — the cheapest of the three. - The avoidable work is the round trip. The handler itself is almost never the problem, which is why "make the handler faster" is the wrong response.
The wait, not the work
Scrolling normally happens on the compositor, entirely off the main thread — that is why a page with a frozen main thread can still be scrolled. A cancellable touchstart or wheel listener breaks that independence: the compositor cannot know whether the gesture is allowed to proceed until the listener has run and declined to cancel it.
That is the entire mechanism, and it explains the otherwise baffling observation that an empty function costs something. The timeline below is schematic and in relative units — the shape is what transfers, and the shape is a gap with nothing of yours in it.
- Finger touches the screen — The input arrives at the compositor first.
- NON-PASSIVE: main thread busy — A render, a hydration step, a chunk being parsed. Nothing to do with scrolling.
- NON-PASSIVE: your listener runs — The handler itself. This is the part people try to optimise.
- NON-PASSIVE: first scroll frame — The gesture only starts moving pixels here. The user has already felt the stall.
- PASSIVE: first scroll frame — The compositor scrolls immediately; no permission needed.
- PASSIVE: scroll continues — Frames keep coming even while the main thread stays busy.
- PASSIVE: your listener runs — Same handler, same cost, now off the critical path entirely.
The handler bar is identical in both rows. Everything that changed is when the compositor was allowed to start.
Saying what you mean, in three places
There are three ways to tell the browser what a region does with a gesture, and they are decided at different times: CSS before the gesture, the listener option at registration, and preventDefault() during the event. Earlier is cheaper, and earlier is also harder to get wrong.
The { passive: false } case deserves the explicit spelling even where it is already the default. Defaults in this area have changed once and are expected to change again; code that relies on them silently becomes a no-op rather than failing loudly.
- Passive is a promise about
preventDefault(), and nothing else. It does not change when or how often your handler runs. event.cancelableisfalseinside a passive listener — the direct way to check at runtime rather than guessing at the current defaults.- The defaults apply to specific types on
window,document,documentElementandbody. Your own element is not covered (How an Event Is Dispatched). - CSS
touch-actionbeats both options: no listener, no round trip, and it applies before the gesture begins (Pointer Events).
1// Tracking only. Say so, and the compositor never waits for us.2window.addEventListener('touchstart', track, { passive: true })3window.addEventListener('wheel', track, { passive: true })4 5// scroll is never cancellable anyway — the win here is the rAF, not the flag6let ticking = false7window.addEventListener('scroll', () => {8 if (ticking) return9 ticking = true10 requestAnimationFrame(() => { readAndUpdate(); ticking = false })11}, { passive: true })12 13// We genuinely cancel this one. Be explicit: the default has already moved once.14canvas.addEventListener('wheel', (e) => {15 e.preventDefault() // custom zoom16 zoom(e.deltaY)17}, { passive: false })18 19// Cheapest of all: decided from style, before any handler exists.20// .canvas { touch-action: none } we own every gesture here21// .carousel { touch-action: pan-y } we take horizontal, the page keeps verticalThe comment on the third listener is the durable part. { passive: false } written down survives a browser policy change; relying on the default does not.
Diagnosing a scroll that stalls
Scroll jank has several causes that produce very similar symptoms, and passive listeners are only one of them. The table separates them by what the evidence actually looks like, because the wrong diagnosis here leads to hours spent optimising a handler that was never the problem.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
Non-passive touchstart on document | Scroll starts late, especially on the first gesture after load | The compositor is waiting for the main thread to decline to cancel | Add { passive: true }, or delete the listener. Check third-party sources first (Third-Party Scripts and the Supply Chain). |
preventDefault() inside a passive listener | A custom gesture silently stopped working after an update | The browser made this listener passive by default; the call is ignored | Register with { passive: false } explicitly, or express it in CSS with touch-action. |
Layout read inside a scroll handler | Scrolling is smooth at first, then degrades as the page grows | Each handler forces a synchronous layout, per scroll event | Record the value, act in requestAnimationFrame (Layout Thrashing). |
| Long task during hydration | The first scroll after load stalls, later ones are fine | The main thread is saturated exactly when the user first tries to move | Break up or defer the work; passive listeners keep the scroll alive meanwhile (Hydration). |
| Too many composited layers | Scrolling is smooth but memory climbs and paint is heavy | Not an event problem at all — a layer problem | Audit layers rather than listeners (Layer Explosion). |
touch-action: none on a scroll container | A region of the page cannot be scrolled by touch at all | The declarative fix was scoped too broadly | Narrow it to the gesture surface, or use pan-y to keep the page's axis. |
How to build it
Most important first.
- Default to
{ passive: true }for anytouchstart,touchmove,wheelorscrolllistener you add. If you are not callingpreventDefault(), say so. - Where you genuinely must cancel a gesture, pass
{ passive: false }explicitly rather than relying on a default that has already changed once. - Prefer
touch-actionin CSS over cancelling touch events. It is declarative, it is decided before the gesture starts, and it does not require a listener to exist (Pointer Events). - Prefer CSS for scroll-linked visual effects:
position: sticky, scroll snap, scroll-driven animations andIntersectionObserverall run without a scroll handler on the main thread (Cheap and Expensive Animation). - If you must handle
scroll, record the value and do the work in arequestAnimationFramecallback. Never read layout inside the handler (Layout Thrashing). - Audit third-party scripts for non-passive listeners. Analytics, chat widgets, carousels and older polyfills are the usual sources, and they are registered on
documentwhere the blast radius is the whole page (Third-Party Scripts and the Supply Chain).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Scrolling is a primary means of navigation for many people, including anyone using a magnifier, where the visible viewport is a small fraction of the page. A scroll that stutters or stalls is a navigation failure, not a polish issue (Keyboard Operability).
- Never cancel scrolling wholesale to build a custom scroll experience. Users depend on the browser's own scrolling for keyboard paging, screen-reader navigation, momentum and find-in-page (What Native Elements Already Do).
- Cancelling a
wheelortouchmoveto implement scroll-jacking also removes reduced-motion behaviour, because the browser can no longer honour the preference for a scroll it is not performing (Contrast, Colour and Motion). - Scroll-linked animation should honour
prefers-reduced-motion, and CSS-driven versions get that check for free where a JavaScript scroll handler does not. - A passive listener is strictly better for assistive technology: it removes a dependency between the main thread and the user's ability to move around the page.
What can go wrong
- A non-passive
touchstartondocumentfrom a vendored library. Scrolling stutters everywhere, the profile shows nothing expensive, and the cause is a registration flag rather than any code that runs. - A drag or zoom implementation that stopped working after a browser update, because its
touchmovelistener became passive by default and itspreventDefault()is now ignored. - Passive added blindly to every listener as a lint fix, including the one that legitimately cancelled a custom gesture. The feature breaks and the commit that broke it looks like a performance improvement.
preventDefault()called inside a passive listener and the console warning ignored, so the cancel has been dead for months and nobody has been told.- The mitigation failing:
touch-action: noneapplied to a scroll container to fix a gesture, which removes the user's ability to scroll that region at all.
- A passive listener can run after the scroll has already moved, so a handler that reads
scrollTopto decide what the gesture meant is reading a position from after its own decision point. - Compositor-driven scrolling and main-thread state can disagree for several frames while the main thread is busy — the page is visibly scrolling while your handler still believes it is not (UI Concurrency: One Thread Owns the Screen).
- A gesture claimed by the browser mid-drag produces
pointercancelwhile your scroll handler continues, so two pieces of state describing the same gesture drift apart (Pointer Events).
- Passive is a scheduling hint, not a security boundary. It grants nothing and restricts nothing beyond the ability to cancel a default (preventDefault vs stopPropagation).
- A non-passive listener registered by third-party script degrades scrolling across the whole page — a real availability cost from code you did not write and cannot audit at runtime (Third-Party Scripts and the Supply Chain).
- Cancelling scroll can be used against the user: preventing them from scrolling away from an interstitial is a dark pattern, and combined with an overlay it is a component of clickjacking (Clickjacking and Framing).
- A passive
touchmovelistener still observes gesture coordinates. Passive limits cancellation, not observation, so the privacy surface of movement tracking is unchanged (Session Replay and the Privacy It Costs).
- "Passive makes the handler faster." It does not touch the handler. It removes the browser's need to wait for it before scrolling.
- "My handler is empty, so it is free." The registration is the cost. The browser cannot know an empty function will not call
preventDefault()unless you tell it. - "Browsers default to passive now, so it does not matter." The defaults apply to specific event types on specific nodes. A
touchmovelistener on your own element is still cancellable by default. - "Just add passive everywhere." Adding it to a listener that legitimately cancels a gesture breaks the feature, and the change looks like a performance fix in review (preventDefault vs stopPropagation).
- "
scrollshould be passive for the same reason." Thescrollevent is not cancellable and fires after the fact. Passive onscrollis harmless and changes no scheduling. - "This is about frame rate." It is about a dependency between the compositor and the main thread. The frames were always affordable; permission to draw them was not (The Frame Budget).
Measuring it, and what changes in the field
- Chromium logs a console violation naming the handler and its duration and suggesting a passive listener. It is the single most direct signal in the module and is very often already in a console people have stopped reading.
- Lighthouse reports non-passive listeners that may delay scrolling as an explicit audit, with the registering source file — the fastest way to find a third-party offender (Measure Before Optimising).
- In the Performance panel, record a scroll: the diagnosis is a gap between the input and the first scroll frame, with a main-thread task filling the gap. The handler entry is usually tiny next to the wait (Debugging Rendering and Jank).
- The Elements panel's Event Listeners pane shows a passive flag per listener, so you can confirm what a library actually registered rather than what its documentation says.
- Field data separates this from a local hunch: scroll and interaction responsiveness on real devices is where a main-thread dependency shows up as a tail, not a mean (Vitals in the Field).
- On a fast device with an idle main thread, a non-passive listener costs almost nothing measurable — which is exactly why it survives development and only appears in field data (Real User Monitoring).
- On a slow device, or during hydration, or while a large chunk is parsing, the same registration produces a visible stall at the very moment the user first tries to scroll (Hydration).
- On desktop with a mouse wheel the effect is smaller than on touch, because touch gestures generate a continuous stream that each inherits the dependency.
- On a long page with many scroll-linked effects, the handler cost stops being negligible and the advice shifts from "make it passive" to "do not use a scroll handler at all" (content-visibility).
- Passive listeners give up the ability to cancel — permanently, for that listener. If a gesture genuinely needs cancelling, you need the non-passive version and you pay the round trip; that is the actual trade, and it is sometimes worth paying.
touch-actionmoves the decision into CSS, where it is declarative and fast and also further from the JavaScript that depends on it. Scoping it too broadly disables scrolling for a whole region.- Replacing scroll handlers with
IntersectionObserveror CSS scroll-driven animation is more code to learn and slightly less direct control, in exchange for effects that keep running when the main thread does not (Cheap and Expensive Animation). - Auditing third-party listeners is ongoing work: a vendor update can reintroduce a non-passive registration at any time, and nothing in your build will flag it.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- BROWSER-SPECIFICThe passive-by-default rules are implementation policy, not specification: Chromium applies them to
touchstart,touchmove,wheelandmousewheelon window, document, documentElement and body, Firefox and Safari adopted the touch defaults but the wheel behaviour and the diagnostics differ — Chromium logs a named console violation, Firefox a differently worded warning, and Safari typically nothing at all. - DEVICE-SPECIFICThe symptom is far more visible on touch than with a mouse wheel, because a touch gesture produces a continuous stream of events that each inherit the main-thread dependency, and on a low-end phone the wait is long enough to be felt where on a desktop it is not.
- SPEC-EVOLVINGWhich event types are passive by default, and on which nodes, has already changed once and is expected to keep changing; treat any list of defaults as current behaviour to verify rather than as a stable contract, and pass
{ passive: false }explicitly wherever cancellation actually matters.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Programming Languages & Runtime Internals — the main thread being unavailable is often garbage collection or JIT warm-up rather than your code, and neither is visible from the event listener you are staring at.