Passive Listeners for Smooth Scroll

A non-passive touchstart, touchmove, or wheel listener forces the compositor thread to wait for the main thread before it can scroll, adding at least one frame of latency to every gesture. Marking the listener { passive: true } lets the compositor scroll immediately. This builds on Scroll and Input Performance, part of Compositing and GPU Acceleration.

Why the Compositor Has to Wait

The compositor thread normally owns the scroll offset and can move a layer without involving the main thread at all. But a wheel or touch* listener is allowed to call preventDefault() to cancel the scroll (think custom carousels or pull-to-refresh). The compositor cannot know whether you will cancel until your handler runs, so when a cancelable (non-passive) listener exists, it dispatches the event to the main thread and blocks the scroll until the handler returns.

If the main thread is mid-task — running a framework render, parsing JSON, recalculating style — the compositor sits idle waiting for it. The gesture is delayed by exactly however long the main thread takes to become free and finish the handler. Even with an empty handler, the round-trip costs one frame: the scroll cannot start on the same frame the event arrived.

Non-passive listener forces a main-thread round-trip A cancelable wheel event is dispatched to the main thread, which blocks the compositor from scrolling until the handler returns. Cancelable (non-passive) wheel event Compositor thread Main thread Wheel arrives Dispatch event to main thread Handler runs may preventDefault() Scroll applied compositor idle the whole time Scroll blocked until the handler returns — at least one frame late

Minimal Reproduction

// ❌ Non-passive wheel listener — compositor must consult the main thread first
// The empty handler still forces a scroll-blocking dispatch on every wheel tick
window.addEventListener('wheel', (e) => {
  // does nothing, but the browser cannot prove that ahead of time
  trackScrollDepth() // imagine a few ms of work here
}) // cancelable event => passive:false by default on this target

Now make the main thread busy so the cost is visible:

// A long task that blocks the wheel handler from running promptly
setInterval(() => {
  const start = performance.now()
  while (performance.now() - start < 120) {} // 120ms of synchronous work
}, 300)

Scroll with the wheel during the busy loop and the page visibly stutters — each gesture stalls until the loop yields, because the non-passive handler cannot run until the main thread is free.

The Trace Signature

[wheel @ t=0ms] — compositor BLOCKED waiting for main thread
├─ Compositor thread: idle (cannot scroll yet)
└─ Main thread (busy 118ms)
    ├─ Long task (104ms)            ← still finishing prior work
    └─ wheel handler (14ms)
[scroll applied @ t=118ms] frame delivered — 7 frames late at 60Hz

The compositor delivered nothing for 118ms even though scrolling itself is sub-millisecond. The whole delay is the main-thread round-trip the non-passive listener forced.

Timeline of a 118ms scroll stall A wheel event at t=0 waits behind a 104ms long task and a 14ms handler before the scroll is applied 118ms later. Compositor Main thread idle — cannot scroll yet Long task (104ms) handler 14ms wheel t=0ms scroll t=118ms 7 frames late at 60Hz

The Fix

Declare the listener passive. This is a promise to the browser that you will never call preventDefault(), so the compositor scrolls without waiting.

Passive versus non-passive scroll path The non-passive path routes through the main thread before scrolling; the passive path scrolls on the compositor immediately and runs the handler off the critical path. Same event, two contracts wheel event non-passive: false route to main thread first Scroll delayed >= 1 frame late passive: true scroll on compositor now Same-frame scroll handler off critical path preventDefault() forbidden — that is the promise
// ✅ Passive listener — compositor scrolls on the same frame, never blocks
// preventDefault() inside a passive handler is ignored (and logs a warning)
window.addEventListener('wheel', (e) => {
  trackScrollDepth() // still runs, but off the scroll critical path
}, { passive: true })

For listeners attached to window, document, or document.body, modern Chrome and Firefox already default touchstart/touchmove/wheel to passive. But any listener added to a specific element, or any third-party script attaching to the document, can silently reintroduce the blocking path — so be explicit. If you genuinely need to cancel scrolling on a small region, scope a non-passive listener to that element only and use CSS touch-action (e.g. touch-action: none) to declare intent so the compositor can still fast-path everything else.

// If you must preventDefault, scope it narrowly and pair with touch-action
sliderTrack.addEventListener('touchmove', onDrag, { passive: false })
// CSS: .slider-track { touch-action: none; } — keeps the rest of the page on the compositor

When the cancel decision can be made from geometry rather than the event, prefer IntersectionObserver and a requestAnimationFrame-batched read instead of a scroll handler — see debouncing scroll-driven layout reads.

Verification

Chrome logs the offending listeners at startup; clear them first:

[Violation] Added non-passive event listener to a scroll-blocking 'wheel' event.
Consider marking event handler as 'passive' to make the page more responsive.
// Confirm gesture latency stays within budget
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.name === 'wheel' || e.name === 'touchstart') {
      const delay = e.processingStart - e.startTime
      if (delay > 16) console.warn(`${e.name} input delay ${delay.toFixed(1)}ms`)
    }
  }
}).observe({ type: 'event', durationThreshold: 16, buffered: true })
Check Target
Non-passive scroll-blocking violations 0 in console
wheel / touchstart input delay < 16ms
Scrolling track during long task still advancing (compositor scroll)
INP from scroll interactions < 200ms

A passing trace shows the Scrolling track advancing even while the Main thread shows a long task — proof the compositor is no longer waiting. The broader interaction budget and the input-delay component are covered in Scroll and Input Performance and measuring INP with the Event Timing API.

Why the Promise Is What Matters

The performance win from { passive: true } is not about the listener running faster — it is about a promise you make to the browser that changes its scheduling. A non-passive touchmove or wheel listener might call preventDefault() to cancel the scroll, so the compositor cannot start scrolling until the listener has run and it knows whether the scroll was cancelled. That wait is a full input-to-scroll latency penalty on every scroll event, and on a busy main thread it can stretch into visible lag. Declaring the listener passive promises it will never cancel the event, which frees the compositor to scroll immediately in parallel with the listener running on the main thread.

Because modern browsers default touchstart and touchmove on the document to passive precisely to avoid this trap, the cases that still matter are explicit wheel listeners and any listener you add to a specific scrollable element. The rule of thumb is to mark every scroll-related listener passive unless you have a concrete, tested reason to cancel the gesture — and if you do need preventDefault(), isolate it to the smallest possible target so the rest of the page keeps its immediate-scroll fast path.

Frequently Asked Questions

If Chrome already defaults window-level wheel and touch listeners to passive, why be explicit?

The intervention only applies to touchstart, touchmove, and wheel attached to window, document, or document.body. Any listener bound to a specific element — a scroll container, a card, a third-party widget’s root — still defaults to passive: false and reintroduces the blocking dispatch. Being explicit documents intent and protects you when a script moves a handler off the window.

Does passive true break preventDefault entirely?

Yes, by design. Inside a passive handler preventDefault() is a no-op and Chrome logs a warning. That is the contract that lets the compositor scroll without waiting. If you genuinely need to cancel a gesture on a region, scope a non-passive listener to that element and pair it with a CSS touch-action declaration so the rest of the page keeps the compositor fast path.

How do I find non-passive scroll-blocking listeners already on my page?

Watch the console at load for [Violation] Added non-passive event listener to a scroll-blocking event. For a runtime view, use getEventListeners(window) in DevTools (or the Elements panel Event Listeners tab) and check the passive flag on each wheel/touch* entry. A PerformanceObserver on event entries with processingStart - startTime > 16ms surfaces the ones actually costing frames.

Should scroll and pointermove listeners also be passive?

scroll events are not cancelable, so passivity does not change scroll dispatch — but keeping their handlers cheap and batched still matters; see debouncing scroll-driven layout reads. pointermove is not a scroll-blocking event either, so the passive flag has no compositor benefit there; the wins are specifically on wheel, touchstart, and touchmove.