EventsBROWSER-SPECIFICDEVICE-SPECIFICSPEC-EVOLVING

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.

The question

Why does adding an empty scroll-related listener make scrolling stutter, and what does { passive: true } actually promise?

The user intent

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.

The obvious build

Add a touchstart or wheel listener to track the gesture. The handler is tiny and returns immediately, so it cannot possibly affect performance.

Why it breaks

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).

How it breaks in a real browser
  • 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 touchstart at all is the signal; an empty function has the identical effect.
  • A listener registered on window or document covers 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.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

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 call preventDefault(). Given that promise, the browser can start the scroll immediately and run your handler whenever it gets to it.
  • Without the promise, touchstart, touchmove, wheel and mousewheel are 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 touchstart and touchmove to passive when the listener is registered on window, document, document.documentElement or document.body, and Chromium extends the same default to wheel and mousewheel. Listeners on other elements are unaffected.
  • Calling preventDefault() inside a passive listener does nothing. Chromium logs a console warning; the event's cancelable is false, so there is nothing to cancel (preventDefault vs stopPropagation).
  • The scroll event 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: none or pan-y tells 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-action set: 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

SIMULATEDThe bars are an Engineer Atlas teaching model, not a measurement: they show the ordering dependency between the compositor and the main thread. Real durations depend entirely on the device and on what else the main thread was doing, and a fast idle machine can make the gap unobservable.

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.

The same gesture, with and without the promiserelative units — an ordering, not a measurement
Finger touches the screen
NON-PASSIVE: main thread busy
NON-PASSIVE: your listener runs
NON-PASSIVE: first scroll frame
PASSIVE: first scroll frame
PASSIVE: scroll continues
PASSIVE: your listener runs
  • Finger touches the screenThe input arrives at the compositor first.
  • NON-PASSIVE: main thread busyA render, a hydration step, a chunk being parsed. Nothing to do with scrolling.
  • NON-PASSIVE: your listener runsThe handler itself. This is the part people try to optimise.
  • NON-PASSIVE: first scroll frameThe gesture only starts moving pixels here. The user has already felt the stall.
  • PASSIVE: first scroll frameThe compositor scrolls immediately; no permission needed.
  • PASSIVE: scroll continuesFrames keep coming even while the main thread stays busy.
  • PASSIVE: your listener runsSame 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.cancelable is false inside 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, documentElement and body. Your own element is not covered (How an Event Is Dispatched).
  • CSS touch-action beats both options: no listener, no round trip, and it applies before the gesture begins (Pointer Events).
Passive by default, explicit where it matters
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 flag
6let ticking = false
7window.addEventListener('scroll', () => {
8 if (ticking) return
9 ticking = true
10 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 zoom
16 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 here
21// .carousel { touch-action: pan-y } we take horizontal, the page keeps vertical

The 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.

Same symptom, four different causes
TriggerSymptomCauseResponse
Non-passive touchstart on documentScroll starts late, especially on the first gesture after loadThe compositor is waiting for the main thread to decline to cancelAdd { passive: true }, or delete the listener. Check third-party sources first (Third-Party Scripts and the Supply Chain).
preventDefault() inside a passive listenerA custom gesture silently stopped working after an updateThe browser made this listener passive by default; the call is ignoredRegister with { passive: false } explicitly, or express it in CSS with touch-action.
Layout read inside a scroll handlerScrolling is smooth at first, then degrades as the page growsEach handler forces a synchronous layout, per scroll eventRecord the value, act in requestAnimationFrame (Layout Thrashing).
Long task during hydrationThe first scroll after load stalls, later ones are fineThe main thread is saturated exactly when the user first tries to moveBreak up or defer the work; passive listeners keep the scroll alive meanwhile (Hydration).
Too many composited layersScrolling is smooth but memory climbs and paint is heavyNot an event problem at all — a layer problemAudit layers rather than listeners (Layer Explosion).
touch-action: none on a scroll containerA region of the page cannot be scrolled by touch at allThe declarative fix was scoped too broadlyNarrow 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 any touchstart, touchmove, wheel or scroll listener you add. If you are not calling preventDefault(), 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-action in 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 and IntersectionObserver all 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 a requestAnimationFrame callback. 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 document where 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 wheel or touchmove to 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

Failure modes
  • A non-passive touchstart on document from 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 touchmove listener became passive by default and its preventDefault() 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: none applied to a scroll container to fix a gesture, which removes the user's ability to scroll that region at all.
What can arrive out of order
  • A passive listener can run after the scroll has already moved, so a handler that reads scrollTop to 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 pointercancel while your scroll handler continues, so two pieces of state describing the same gesture drift apart (Pointer Events).
Security
  • 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 touchmove listener 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).
Misreads
  • "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 touchmove listener 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).
  • "scroll should be passive for the same reason." The scroll event is not cancellable and fires after the fact. Passive on scroll is 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

How you would see this
  • 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).
Slow device, slow network, large data, old tab
  • 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).
What this costs
  • 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-action moves 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 IntersectionObserver or 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, wheel and mousewheel on 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.

Computer Architectureinterrupts
Domains that do not exist yet
  • 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.