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.
Keeping Scroll on the Compositor
Scrolling is fast by default because the compositor thread handles it independently of the main thread β it simply re-composites the existing layers at a new offset, which is why a page can scroll smoothly even while the main thread is busy. The ways to break that fast path are what this topic is about. A non-passive touchmove or wheel listener forces the compositor to wait for the main thread to confirm the event was not cancelled before it can scroll, adding input latency; declaring the listener { passive: true } removes that wait, as detailed in passive listeners for smooth scroll. A scroll handler that reads layout forces a synchronous reflow on every scroll tick, which is fixed by batching the read into a requestAnimationFrame or replacing it with IntersectionObserver, covered in debouncing scroll-driven layout reads.
The unifying principle is to keep work off the input-to-scroll critical path. Anything the browser must do synchronously between the userβs gesture and the scroll appearing on screen is latency the user feels, so the goal is to let the compositor scroll immediately and do your own work β analytics, lazy loading, position tracking β asynchronously and off the main thread where possible. position: sticky and IntersectionObserver exist precisely so that common scroll-linked effects can run without a main-thread scroll handler, and preferring them over a JavaScript scroll listener is usually the single biggest win for scroll smoothness.
Input Latency and INP
Scroll performance and input responsiveness are two facets of the same constraint: the main thread must be free to respond when the user acts. A long task blocking the main thread when a tap arrives shows up as input delay β the gap before your handler even starts β which is the first of the three phases the Event Timing API attributes INP to. So the same discipline that keeps scrolling smooth (short tasks, no forced reflows, work off the critical path) also keeps INP low, because both are ultimately about not monopolising the main thread. A page that scrolls jankily and a page that responds slowly to taps usually share a cause: too much synchronous work on the thread that has to service input.
The practical consequence is that you can attack both with one set of habits. Break long tasks so the main thread has gaps to handle input; keep event handlers short and free of synchronous layout reads; use passive listeners so scroll never waits on your code; and move continuous measurement to observers that run off the main thread. Measuring the result means watching INP in the field alongside dropped-frame counts during scroll, both of which the tooling in rendering performance metrics and tooling captures. When INP is high, the attribution phase tells you whether the fix is task-chunking (input delay), a shorter handler (processing), or batched reads (presentation delay).
Prefer the Platformβs Scroll Primitives
The most reliable way to keep scroll on the compositor is to avoid a JavaScript scroll handler entirely where the platform offers a declarative alternative. position: sticky handles the common βpin this element as it scrollsβ effect natively, on the compositor, with no handler to run per scroll event. IntersectionObserver answers βis this element in view?β and βhow far has it scrolled into view?β off the main thread, replacing the getBoundingClientRect()-in-scroll pattern that forces a reflow on every tick. Scroll-linked animations increasingly have a declarative form too. Each of these moves a scroll-linked effect off the input-to-scroll critical path, which is exactly where latency is felt.
When you genuinely need a scroll handler, keep it minimal and off the critical path: mark it passive so the compositor never waits on it, batch any geometry read into a requestAnimationFrame so it runs once per frame against a clean layout, and defer non-essential work like analytics to idle time. The through-line with input responsiveness is that both depend on a free main thread, so the same habits β short handlers, no forced reflows, passive listeners, work off the main thread β improve scroll smoothness and INP together. Reaching for a platform primitive before a hand-rolled handler is usually the single largest improvement available, because the primitive runs where your handler cannot: on the compositor, immune to a busy main thread.
Frequently Asked Questions
Does marking a scroll listener passive stop preventDefault from working?
Yes. A { passive: true } listener is a promise to the browser that you will never call preventDefault(), so the compositor scrolls without waiting for the main thread. If your code does call preventDefault() inside a passive listener the browser ignores it and logs a console warning. Only mark a listener passive when you genuinely never need to cancel the gesture β a custom pull-to-refresh or a canvas that owns the wheel gesture must stay non-passive.
Why does my page scroll smoothly even though a long task is blocking the main thread?
Because a normal document scroll is handled entirely on the compositor thread. The compositor owns the scroll offset and repositions the already-rasterized layer without touching style, layout, or paint. A frozen main thread only shows up as jank when your scroll effect depends on main-thread work β a scroll handler that reads geometry, or a non-passive listener that forces a main-thread round-trip.
Which INP component does passive scrolling actually improve?
Passive listeners mostly reduce the input delay portion for scroll and wheel interactions, because the compositor no longer waits for the main thread to confirm the gesture. It does not shrink processing time β that is your handlerβs own work β and it does not affect presentation delay. For tap and click interactions the bigger lever is breaking up the long tasks that inflate input delay, since the tap cannot be dispatched until the main thread is free.
How do I find non-passive scroll-blocking listeners in an existing app?
Open the DevTools console and look for the [Violation] Added non-passive event listener to a scroll-blocking event message, which Chrome logs at registration time. The Performance panel also shows a Handler entry under each input event with its duration, and the Rendering tabβs Scrolling performance issues overlay highlights regions that fell back to main-thread scrolling.
Is requestAnimationFrame enough to fix a janky parallax scroll handler?
Batching the layout read into requestAnimationFrame prevents forced synchronous layout on every scroll event and coalesces many events into one measured frame, which removes most of the thrash. It is not a complete fix if the effect still writes an expensive property such as top or width that triggers layout β animate transform and opacity instead so the change stays on the compositor. For visibility-only logic, IntersectionObserver avoids reading geometry entirely.
Related Guides
- Passive Listeners for Smooth Scroll β the exact registration contract that keeps scroll on the compositor thread.
- Debouncing Scroll-Driven Layout Reads β batching geometry reads into rAF to avoid forced synchronous layout.
- Forced Synchronous Layouts β why a read-after-write in a handler flushes layout every frame.
- Measuring INP with the Event Timing API β instrumenting the input-delay, processing, and presentation breakdown in the field.
- Compositing and GPU Acceleration β the parent overview tying scroll, layers, and off-main-thread work together.