Scroll and Input Performance

Scrolling and pointer input are the two interactions where users feel jank most acutely, because both are driven by the compositor thread on a tight per-frame deadline. This topic covers how the compositor handles scroll off the main thread, why a single badly-registered listener can force it to wait, and how to keep input latency inside the frame budget. This is part of Compositing and GPU Acceleration.

A modern browser can scroll a page entirely on the compositor thread: it owns the scroll offset, moves the layer, and submits the frame without ever waking the main thread. That fast path only survives if your JavaScript stays out of the way. The moment a non-passive listener, a synchronous layout read, or a heavy scroll handler appears, the compositor has to fall back to the main thread and the 16.6ms budget evaporates.

Compositor scroll path versus a main-thread-blocked scroll path The top lane shows scroll handled entirely on the compositor thread within budget. The bottom lane shows a non-passive listener forcing the compositor to wait for the main thread, blowing the frame budget. Passive: compositor scroll Non-passive: blocked scroll Input event Compositor scrolls Frame ~4ms Input event Wait for main listener may preventDefault Frame ~28ms

How the Compositor Handles Scroll

When a page first paints, the browser builds a layer tree and decides which layers can scroll independently. For a normal document scroll, the compositor thread holds the scroll offset directly. An incoming wheel, touch, or trackpad gesture updates that offset and re-submits the existing GPU textures shifted by a few pixels β€” no style, no layout, no paint. This is why a frozen main thread (a long task chewing through JSON) can still scroll smoothly: the work simply isn’t on the critical path.

The compositor can only take this path if it can prove ahead of time that your code will not cancel the gesture. That proof comes from how you registered your listeners.

Passive vs Non-Passive Listeners

A touchstart, touchmove, or wheel listener is, by default in modern browsers, treated as non-passive on the document β€” meaning it is allowed to call preventDefault() to cancel scrolling. The compositor cannot start scrolling until it knows whether you will cancel, so it dispatches the event to the main thread and waits for the handler to return before moving the layer. If the main thread is busy, that wait is the latency.

// ❌ Non-passive: compositor must wait for the main thread before scrolling
// each gesture pays a main-thread round-trip β€” the "scroll-blocking" handler
window.addEventListener('wheel', onWheel) // defaults to passive:false for cancelable events

// βœ… Passive: promises never to preventDefault, so the compositor scrolls immediately
window.addEventListener('wheel', onWheel, { passive: true })

Marking a listener { passive: true } is a contract: the browser knows preventDefault() will be ignored, so it scrolls on the compositor without consulting the main thread at all. The full mechanics and the one-frame latency penalty are covered in passive listeners for smooth scroll.

Scroll-Linked Effects and Layout Reads

The second way to break compositor scroll is a scroll handler that reads geometry. Parallax headers, sticky elements, and scroll-progress bars often call getBoundingClientRect() or read offsetTop on every event. Each read forces the browser to flush pending layout synchronously so it can return a fresh value β€” a forced synchronous layout β€” and the scroll event fires far more often than once per frame.

// ❌ Reads layout on every scroll event β€” forces synchronous layout each time
element.addEventListener('scroll', () => {
  const top = header.getBoundingClientRect().top // forces layout flush
  badge.style.transform = `translateY(${-top}px)`  // write after read = thrash
})

The fix is to batch the read into requestAnimationFrame and prefer IntersectionObserver for visibility checks β€” detailed in debouncing scroll-driven layout reads.

Input Latency and Hit-Testing

Beyond scroll, every tap and click must be routed to the correct element. The compositor performs a fast hit-test against the layer tree; if the hit lands on a region with a non-passive listener or a complex stacking context, it escalates to the main thread. A long task already running there delays the event from being processed at all. This input delay is the first of the three components of Interaction to Next Paint: input delay, processing time, and presentation delay.

[Tap on button] INP = 312ms β€” POOR
β”œβ”€ Input delay (240ms)        ← long task blocking the main thread
β”‚   └─ Script Evaluation (240ms) parsing analytics payload
β”œβ”€ Processing time (38ms)
β”‚   └─ click handler + style recalc
└─ Presentation delay (34ms)
    └─ Layout (19ms) + Paint (8ms) + Composite (7ms)

The 240ms input delay is the dominant cost and has nothing to do with the handler itself β€” it is the main thread being unavailable when the tap arrives. Breaking up long tasks (see observing long tasks with PerformanceObserver) is the highest-leverage fix.

Cost Model

Scroll path Condition Per-frame cost
Compositor scroll All scroll-region listeners passive ~2–4ms (compositor only)
Main-thread escalation Non-passive wheel/touchstart +1 frame latency per gesture
Scroll-linked read getBoundingClientRect in handler +3–10ms forced layout per event
Hit-test escalation Tap over non-passive region +input delay (= current long task)

Diagnostic Checklist

  • In DevTools Performance, a green Scrolling track with no Main-thread activity means you are on the compositor fast path.
  • Chrome logs [Violation] Added non-passive event listener to a scroll-blocking event to the console β€” search for these first.
  • The Performance panel flags handlers with a Handler entry under the input event; durations above 4ms warrant a requestAnimationFrame batch.
  • The Rendering tab’s Scrolling performance issues overlay highlights regions that forced main-thread scrolling.

Metric Validation

Track INP and long-animation-frame entries to confirm the interaction path stays within budget. Field INP under 200ms is the β€œgood” threshold; the breakdown is available through the Event Timing API.

// Confirm interactions stay within budget
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.interactionId) {
      const total = e.duration
      const inputDelay = e.processingStart - e.startTime
      if (total > 200) console.warn(`INP candidate ${total}ms, input delay ${inputDelay}ms`)
    }
  }
}).observe({ type: 'event', durationThreshold: 16, buffered: true })
Metric Target How measured
INP (field) < 200ms Event Timing API / CrUX
Scroll handler duration < 4ms Performance panel Handler entry
Non-passive scroll listeners 0 on scroll regions Console violation log
Compositor-only scroll yes Scrolling track with idle main thread

For deeper INP instrumentation see measuring INP with the Event Timing API, and to keep scroll-linked effects off the main thread continue to passive listeners for smooth scroll and debouncing scroll-driven layout reads.