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.
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 eventto the console β search for these first. - The Performance panel flags handlers with a Handler entry under the input event; durations above 4ms warrant a
requestAnimationFramebatch. - 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.