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.

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.

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