Web Platform · Rendering Performance
Scrolling and the Compositor Thread: Why Scroll Handlers Break Smoothness
How compositor-driven scrolling works, why a non-passive wheel listener forces the main thread into the loop, and which scroll-linked effects stay off it.
Scrolling is the interaction users judge a page by, and it is the one interaction the compositor can perform without the main thread. When a wheel or touch gesture arrives, the compositor thread moves the layer’s tiles, and the display shows new content on the next refresh — even if a script has occupied the main thread for a second. That property holds only while the browser is certain that no listener will cancel the scroll.
The compositor scroll path
For a scroll container whose content is composited, scrolling is a translation. The tiles already rastered for the visible and nearby regions are shifted, and newly exposed tiles are rastered by worker threads. No style, layout, or paint is involved on the main thread, and the visible response is bounded by the refresh interval: up to 16.7 ms at 60 Hz, 8.3 ms at 120 Hz.
Two things can break the path. The first is a listener that might call preventDefault() on the input event, because cancellation must be decided before the scroll happens and only the main thread can run the listener. The second is content that must be re-rendered as it scrolls — fixed or sticky elements, or effects tied to scroll position that are not expressible as compositor transforms.
What a non-passive listener actually costs
addEventListener accepts an options object with a passive flag. A passive listener promises not to cancel the event, so the browser can scroll immediately and dispatch the event afterwards. A non-passive listener forces the browser to wait for the event to be dispatched and the listener to return before it can know whether to scroll.
The cost is not a fixed number of milliseconds. It is the remainder of whatever task is currently running on the main thread, plus the dispatch and execution time of the listener itself. A 200 ms task in progress when a wheel event arrives means up to 200 ms before the scroll begins, and that delay is perceived as the page being stuck rather than as a slow scroll.
Engines mitigate the common case by making listeners passive by default for touchstart, touchmove, and wheel when registered on the window, the document, or the document body. The default does not extend to arbitrary elements, and it does not help a listener that genuinely needs to cancel, such as a custom drag interaction or a horizontal carousel. For those, touch-action: pan-y on the element declares the axis the browser may handle, which lets the compositor take vertical panning while script handles the horizontal axis. The declaration is evaluated at hit-test time, before the gesture begins, so it avoids the round trip that a listener would require.
// Passive: the compositor scrolls without waiting for the handler.
window.addEventListener('wheel', onWheel, { passive: true });
// Non-passive: the browser must ask the main thread before scrolling.
carousel.addEventListener('touchmove', onDrag, { passive: false });
The order of scroll events, style, and animation callbacks
Scroll events are dispatched as part of the frame’s update, in the scroll steps, which run before animation frame callbacks. The ordering within a frame is therefore: scroll events, then media query evaluation and animation events, then the callbacks registered with requestAnimationFrame, then style and layout.
The ordering has a practical consequence: a scroll handler that mutates style runs before the frame’s style pass, so its writes are batched with everything else and cost one style and layout pass, not two. A scroll handler that reads geometry forces layout earlier, which is the same trap described in Forced Synchronous Layout, and it is common in scroll effects because the natural implementation reads scrollY and writes a transform.
Scroll events are also rate-limited. They are fired once per frame while scrolling, not once per input device sample, so a 120 Hz touch digitizer reports at most 120 events per second and usually fewer. The event itself carries no position or delta, unlike a wheel event, which is why it is a poor place to compute positions: scrollTop and getBoundingClientRect() are the sources, and both may force layout.
Effects that stay off the main thread
Declarative scroll-driven animations tie an animation’s progress to a scroll container’s scroll offset through a timeline, rather than through a listener. Where the animated property is one the compositor can apply, the engine samples the timeline and updates the effect without main-thread work. Where the property requires style or layout — for example an animation of width or background-color — the timeline still avoids the listener but the per-frame work returns to the main thread, and the animation costs the same as any other main-thread animation.
The same logic applies to a hand-written effect. A transform driven from scrollY in a scroll handler can be made compositor-friendly only if the transform is on a promoted layer and the value can be expressed without layout; even then, the handler runs on the main thread and is subject to main-thread delay. There is no general mechanism for arbitrary script to run per compositor frame; the compositor can only interpolate values it was given in advance.
This is the design boundary: the compositor is a fixed-function animator of transforms and opacity, not a general runtime. Anything requiring computation per frame is main-thread work by definition.
Fixed, sticky, and background attachment
position: fixed elements are positioned relative to the viewport rather than the document, so their position does not change as the page scrolls, but their containing layer does. Depending on how the engine assigns layers, a fixed element may be promoted so the compositor can hold it in place, or it may be repositioned in a layer that scrolls, which forces work per frame.
position: sticky is more expensive in principle because the element’s position depends on the scroll offset relative to its containing block: it must be recomputed as the box moves between its normal position and its stuck position. Engines handle sticky positioning during scroll on the compositor for simple cases, but a sticky element inside a transformed ancestor, or combined with a changing size, can fall back to main-thread updates. Because sticky positioning participates in layout, a sticky element whose stuck state changes the size of its container triggers layout during scroll.
background-attachment: fixed is a known source of scroll cost. The background must remain fixed relative to the viewport while the element moves, which prevents the layer from being scrolled as a bitmap; engines may fall back to repainting. A fixed-position background layer, or a transform on a promoted element, achieves the same visual effect without the attachment mode.
Scroll anchoring and content that arrives late
Scroll anchoring is the mechanism that keeps the visual position stable when content above the current scroll position changes size: an image without dimensions loads, a section expands, or a content-visibility placeholder is replaced by real content. The browser picks an anchor node in the viewport and adjusts the scroll offset when content above it changes size, so the user does not see the page jump.
The mechanism conflicts with programmatic scrolling. A script that sets scrollTop while anchoring adjusts the offset can find its value overridden, and code that measures scroll position across a layout change can observe a different value than it wrote. Containers that manage their own scroll — virtualized lists, carousels with snap points — often need overflow-anchor: none to disable the adjustment for that container.
Scroll snapping has a related cost: the snap position is computed after layout and after each scroll gesture, and a snap container whose snap points depend on content size will recompute them whenever content changes. Neither anchoring nor snapping is expensive on its own; both become visible when combined with content that resizes during scroll, which is precisely when frames are already tight.
What to inspect when scrolling stutters
The first question is which thread is missing the frame. If the main thread is busy and the compositor is idle, the scroll is being blocked by a listener or by main-thread work; the fix is passive listeners, touch-action, and shorter tasks. If the compositor is busy and the main thread is idle, the cost is raster or layer management: too many layers, large blurred surfaces, or tiles being created and destroyed during scroll. If both are idle and frames still drop, the work is in the display pipeline or the GPU, which is outside both threads.
The layer and raster view answers the second case directly, and Painting, Tiles, and Raster Cost explains what the tile boundaries imply. For the first case, Frame Budgets, Long Tasks, and Input Latency covers the scheduling side, and Compositing and Layer Promotion covers the layer decisions that determine whether a scroll container can be scrolled by the compositor at all.
References
- DOM Standard — Events — WHATWG
- Scroll-driven Animations — W3C
- CSS Positioned Layout Module Level 3 — W3C
- CSS Scroll Anchoring Module Level 1 — W3C