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.

Compositor-owned scroll offset applied to a cached layer The compositor thread holds the scroll offset and re-submits the same cached GPU texture shifted by a few pixels, bypassing style, layout and paint on the main thread. Main thread (skipped) Style Layout Paint Compositor thread Wheel / touch delta = 40px Update scroll offset owned by compositor Reposition cached layer no re-raster Submit GPU frame Same texture, shifted a few pixels β€” the main thread can be frozen and scroll still runs.

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.

Read-write thrash inside a scroll handler versus a batched rAF read Reading geometry then writing style in the same scroll handler forces a synchronous layout flush every event, while batching the read into requestAnimationFrame collapses many events into one measured frame. Thrash: read then write per event getBounding… Forced layout synchronous flush style.transform Layout dirtied next read re-flushes Batched: coalesce reads in rAF N scroll events queue offset no layout read rAF reads once one flush per frame Write transform composited, no re-read

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.

The three components of an INP interaction on a timeline A timeline from tap to next paint split into input delay, processing time and presentation delay, with input delay dominated by a blocking long task on the main thread. Tap β†’ Next Paint (INP = 312ms) Tap arrives Input delay 240ms long task: analytics parse blocks main thread Processing 38ms Presentation 34ms layout+paint handler never ran β€” the cost is the thread being busy frame committed

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.

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.