PaintGENERALENGINE-SPECIFICDEVICE-SPECIFIC

Scroll and Input Latency

Scrolling is handled by the compositor when it can be, and by the main thread when your code forces it — which is why one listener can make a whole page feel broken.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Why does scrolling stay smooth on a busy page sometimes and stutter badly other times, and what decides which?

The user intent

Someone flicks a long list with their thumb. They expect the content to move with the finger — the single most direct physical metaphor in an interface, and the one where a delay of a few frames is unmistakable.

The obvious build

Scrolling is the browser's job. It happens automatically, so it cannot be something my code makes slow — and if I need to react to it, a scroll listener is the obvious way.

Why it breaks

Scroll can be handled entirely by the compositor, on its own thread, without the main thread being consulted at all. That is why a page can scroll perfectly while it is otherwise frozen — and why losing that path is such a sharp regression.

How it breaks in a real browser
  • Scroll can be handled entirely by the compositor, on its own thread, without the main thread being consulted at all. That is why a page can scroll perfectly while it is otherwise frozen — and why losing that path is such a sharp regression.
  • A non-passive touchstart, touchmove or wheel listener forces the browser to ask the main thread whether the event will be cancelled *before* it may scroll. If the main thread is busy, the scroll waits for it, and the content visibly lags the finger (Passive Listeners).
  • A scroll listener that reads geometry forces a synchronous layout in the middle of scrolling, which is the most reliable way to convert a compositor scroll into a main-thread one (Layout Thrashing).
  • Fast scrolling can outrun raster. Tiles that have not been rasterised yet appear as blank or checkerboarded regions, which is a raster problem and entirely unrelated to your JavaScript.
  • position: fixed headers, scroll-linked animations and parallax effects all couple visual output to scroll offset, and any of them implemented on the main thread reintroduces the dependency the compositor was avoiding.
  • Input latency is not only scroll. A click handler that runs long delays the visual response to the click, and the user cannot distinguish "the app is thinking" from "the click did not register" (Interaction Responsiveness).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • The compositor thread receives input events first. For a scroll gesture over a region it knows how to scroll, it can update the scroll offset and produce frames on its own — no main thread, no style, no layout, no paint.
  • It can only do that if it knows in advance that no listener will cancel the gesture. The browser therefore tracks, per region, whether any blocking (non-passive) touch or wheel listener exists. If one does, the compositor must wait for the main thread to run it and report back.
  • This is why { passive: true } matters so much: it is a promise that the listener will not call preventDefault(), which lets the compositor scroll immediately and run your listener whenever it gets around to it.
  • Scroll *events* are dispatched to the main thread asynchronously, after the fact. A scroll listener is a notification, not a hook — the scroll has already happened, and anything you draw in response is one or more frames behind (The Rendering Opportunity).
  • The compositor rasterises tiles ahead of the scroll position, within a memory budget. Outrun that budget and there is nothing to draw, which is the checkerboard.
  • For non-scroll input, the path is different but the constraint is the same: the event is queued on the main thread, and it cannot be dispatched until the current task finishes. Input latency is therefore mostly a queueing problem, not a handler-speed problem (Long Tasks).

What this makes the browser do

And which of it is avoidable.

  • Hit-testing the gesture against the scrollable regions to decide which scroller is targeted and whether the compositor may handle it.
  • Checking for blocking listeners in the event region — and, where one exists, a round trip to the main thread per gesture segment.
  • Rasterising tiles ahead of the visible region, discarding tiles behind it, and re-rasterising when the scroll reverses direction.
  • Running scroll-linked effects: sticky positioning, scroll-driven animations and scrollbar updates, some of which the compositor can do and some of which it cannot.
  • Avoidable: main-thread work per scroll event, forced synchronous layouts inside scroll handlers, and blocking listeners registered on document for gestures that never needed cancelling.

Two scroll paths, and what moves you between them

There are effectively two ways a page scrolls. In the fast path the compositor owns the gesture: it updates the offset and produces frames without consulting the main thread, which is why scrolling survives a frozen page. In the slow path the compositor must wait for the main thread — because a blocking listener might cancel the gesture, or because something scroll-linked can only be computed there.

Almost every scroll performance problem is really the question "which path am I on, and what put me there". The answer is usually a listener, and frequently not one you wrote.

What moves a scroll onto the main thread
TriggerSymptomCauseResponse
Non-passive touchmove or wheel listener in the event regionContent lags the finger, worst when the app is busyThe compositor must wait to learn whether the gesture will be cancelledAdd { passive: true }; if cancelling is genuinely needed, scope the listener to the smallest element and keep it trivial (Passive Listeners).
Scroll handler reading layoutStutter proportional to page complexityForced synchronous layout inside the handler, once per eventRecord the offset in the handler; do the reads and writes in one requestAnimationFrame callback (Layout Thrashing).
Parallax or scroll-linked animation in JavaScriptThe effect visibly trails the scrollThe visual output depends on a main-thread computation that runs after the compositor has already movedUse position: sticky, IntersectionObserver, or scroll-driven animations where available; accept a frame of lag otherwise.
Third-party analytics or chat widgetScrolling degrades after the widget loadsA document-level blocking listener registered by code you do not controlFind it with the event-listener overlay, then ask the vendor or load the widget in a way that does not touch the document (Third-Party Scripts and the Supply Chain).
Very long DOM with expensive paintBlank or checkerboarded regions on fast flicksRaster cannot keep tiles ahead of the scroll within its memory budgetVirtualise, apply content-visibility to off-screen sections, and simplify paint on the scrolling surface (content-visibility).
Custom wheel-driven smooth scrollingFeels wrong on trackpads, breaks keyboard scrolling and screen-reader scrollingPlatform scroll behaviour has been replaced by a main-thread approximationDelete it. Use scroll-behavior: smooth if smoothness is the goal, and honour reduced motion.

The latency the user actually feels

SIMULATEDThe proportions come from an Engineer Atlas teaching model rather than a measurement; the relationship between queueing delay and handler duration is the transferable part, and real ratios depend entirely on what else the page is doing. Field interaction-latency data is where the real distribution lives (Vitals in the Field).

For non-scroll input, the perceived delay is dominated by the wait *before* your handler runs, not by the handler itself. An event that arrives while a long task is executing sits in the queue until that task completes, and only then is dispatched, processed, and reflected in a frame.

The timeline below shows the shape in relative units. The lesson is the first bar: the handler is not the problem, and optimising it while leaving the blocking task in place changes almost nothing.

A tap that arrives mid-taskrelative units — the shape of the delay, not a measurement
Long task already running
Tap queued (user is waiting)
Event dispatch + handler
Style + layout
Paint + composite
Pixels the user sees
  • Long task already runningRuns to completion; it cannot be preempted. The tap has already happened and is waiting (Long Tasks).
  • Tap queued (user is waiting)This is the bulk of the perceived latency, and none of it is your handler.
  • Event dispatch + handlerThe part most people optimise. It is usually the smallest bar.
  • Pixels the user seesThe user measures from the tap, not from the dispatch — the whole bar is their experience of "how fast is this".

Shortening the handler shortens one small bar. Breaking up the long task shortens the large one (Yielding and Scheduling).

Reacting to scroll without paying for it

The most common scroll handler in the world reads a position and updates a class. Written directly it forces layout on every event and runs far more often than the display refreshes. Written against the frame it runs once per frame and reads once.

Show a shadow on the header once the page has scrolled
Read layout per event
window.addEventListener('scroll', () => {
  // forced synchronous layout, once per scroll event
  const top = content.getBoundingClientRect().top
  header.classList.toggle('is-stuck', top < 0)
})
Let the platform tell you
// No scroll listener at all: the browser reports the crossing
const sentinel = document.querySelector('#top-sentinel')!
new IntersectionObserver(
  ([entry]) => header.classList.toggle('is-stuck', !entry.isIntersecting),
  { threshold: 0 },
).observe(sentinel)

The first version forces a layout inside a handler that fires more often than frames are produced, so the browser recomputes geometry it is about to recompute anyway — and it does so on the thread the scroll may be waiting on. IntersectionObserver computes the same answer off the critical path and delivers it as a callback only when the answer changes, so the common case costs nothing at all. Where the effect is purely visual, position: sticky removes even the callback.

When you really do need the offset
1let latest = 0
2let queued = false
3
4window.addEventListener(
5 'scroll',
6 () => {
7 latest = window.scrollY // a read the browser already has: no layout forced
8 if (queued) return
9 queued = true
10 requestAnimationFrame(() => {
11 queued = false
12 applyEffect(latest) // runs at most once per frame
13 })
14 },
15 { passive: true },
16)

Two things are doing the work here: passive: true keeps the compositor from waiting on this listener at all, and the frame-callback gate collapses many events into one visual update. Note what is *not* here — no getBoundingClientRect(), because reading it inside the handler would force the layout this pattern exists to avoid.

How to build it

Most important first.

  • Mark every touch and wheel listener { passive: true } unless you genuinely intend to cancel the gesture, and register cancelling listeners on the narrowest element rather than on document (Passive Listeners).
  • Do nothing expensive in a scroll listener. Record the offset, and do the work in a requestAnimationFrame callback so it happens once per frame rather than once per event.
  • Prefer declarative platform mechanisms over scroll listeners entirely: position: sticky for headers, IntersectionObserver for "is it visible", CSS scroll snapping for paging, and scroll-driven animations where supported (content-visibility).
  • Reduce what has to be rasterised: virtualise long lists, use content-visibility on off-screen sections, and keep scrolling surfaces free of expensive paint commands (List Virtualization).
  • Keep tasks short so input is never queued behind them. Responsiveness is dominated by the wait before your handler runs, not by the handler (Yielding and Scheduling).
  • Give visual feedback within the first frame of an interaction even if the real work is slower — a pressed state costs almost nothing and changes the perceived latency completely.
  • Never hijack the scroll. Custom smooth-scrolling that intercepts wheel events replaces a compositor-driven, accessible, momentum-correct behaviour with a main-thread approximation of it.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A dropped-frame scroll is disorienting. Irregular motion is harder for the visual system to track than smooth motion, and for users with vestibular disorders, migraine sensitivity or motion sickness, a stuttering scroll is not a minor annoyance but a reason to stop using the product.
  • Scrolling must remain possible from the keyboard. Arrow keys, Page Up/Down, Home and End work on a focusable scroll container by default and stop working the moment a custom scroller reimplements the behaviour without a tabindex and without those key bindings (Keyboard Operability).
  • Hijacked scrolling breaks assistive technology in ways that are not obvious visually: screen readers scroll the page to bring focus into view, and a custom scroller that ignores programmatic scrolling leaves the reader announcing content the user cannot see.
  • Scroll-linked animation is animation. prefers-reduced-motion covers parallax and scroll-driven effects, and those are among the most reliable triggers of vestibular symptoms because the motion is coupled to the user's own input (Contrast, Colour and Motion).
  • Input latency affects switch-device and voice-control users disproportionately, because each of their interactions is more expensive to produce. A dropped or delayed activation costs them far more than it costs a mouse user.
  • Never disable zoom or overscroll behaviour to make a gesture feel better. Both are accessibility features that some users depend on to read the page at all.

What can go wrong

Failure modes
  • A third-party script registering a blocking touchstart listener on document, which degrades scrolling across the entire page for reasons that appear nowhere in your code (Third-Party Scripts and the Supply Chain).
  • A scroll handler that reads getBoundingClientRect() per event: layout is forced per event, and the events arrive faster than layout completes.
  • Checkerboarding on fast flicks — raster cannot keep ahead, and the user sees empty regions where content should be.
  • Scroll anchoring surprises: content loading above the viewport shifts what the user is reading, which is a visual stability failure rather than a latency one (Visual Stability).
  • The mitigation failing: throttling a scroll handler to a fixed interval, which decouples it from the frame and produces effects that visibly lag or jitter against the scroll.
  • Custom momentum implementations that feel subtly wrong on every platform, because the real one is tuned per operating system and per input device.
What can arrive out of order
  • Scroll events are delivered asynchronously, so the offset you read in a handler may already be stale — the compositor has scrolled further while your task was queued.
  • A blocking listener races the gesture: the compositor waits for the main thread, and if the main thread takes too long some engines time out and scroll anyway, so preventDefault() may be ignored.
  • Content arriving above the viewport races the user's reading position; scroll anchoring tries to compensate, and the result depends on whether the insertion landed before or after the anchoring pass (Visual Stability).
  • A programmatic scrollTo issued while a momentum scroll is in flight can be overridden by the gesture, or override it, depending on ordering — which is why scroll restoration is so fiddly (Scroll Restoration).
Security
  • The browser does not stop a page from making itself unusable. A page that blocks scrolling is a bad page, not a policy violation, and only the user's decision to leave resolves it.
  • Scroll position and scroll-linked timing are observable and have been used for fingerprinting and for inferring what a user is reading. Reporting scroll depth to analytics is a privacy decision, not just a product one (Session Replay and the Privacy It Costs).
  • Input event timing is a side channel: engines coarsen timestamps and restrict high-resolution timers precisely because precise input timing can leak user behaviour and, historically, cross-origin state.
  • A transparent overlay intercepting pointer events over a real control is the clickjacking construction, and the fact that the page scrolls normally makes it less noticeable, not more (Clickjacking and Framing).
Misreads
  • "Scrolling is the browser's job, so I cannot make it slow." One blocking listener registered anywhere in the page is enough to make it slow everywhere.
  • "scroll events fire before the scroll." They are dispatched after the fact. Anything you draw in response is behind the content the compositor already moved.
  • "Throttling the handler fixes it." Throttling reduces the number of forced layouts and decouples the effect from the frame. Use a frame callback instead.
  • "The scroll is smooth, so the page is responsive." A compositor-driven scroll is smooth on a page whose main thread is entirely blocked; try tapping something.
  • "Passive listeners are a micro-optimisation." They are the difference between the compositor scrolling immediately and waiting for a round trip to a busy main thread.
  • "Custom smooth scrolling feels premium." It replaces platform behaviour tuned per device and per assistive technology with an approximation that is worse for everyone who is not using a mouse on a desktop.

Measuring it, and what changes in the field

How you would see this
  • The Performance panel with a recorded scroll: look for main-thread activity during the gesture. A compositor-driven scroll shows frames with essentially nothing on the main thread.
  • The scrolling-performance and event-listener overlays in the rendering tools, which highlight regions with blocking listeners — usually the fastest way to find a third-party culprit.
  • Checking for forced synchronous layout warnings inside scroll handlers in the profile (Debugging Rendering and Jank).
  • Interaction latency in the field, which captures the queueing delay real users experience and which local profiling systematically underestimates (Interaction Responsiveness).
  • Long-task attribution to identify which script is holding the main thread when input arrives (Event-Loop Lag: One Callback, Everybody Waits).
Slow device, slow network, large data, old tab
  • On a touch device, the gesture is continuous and the coupling to the finger makes even one frame of delay perceptible; a mouse wheel is discrete and far more forgiving.
  • On a slow device, raster falls behind sooner and blocking listeners cost more, so the same page scrolls acceptably on a laptop and badly on a phone.
  • On a high-refresh display, the scroll is sampled more often and there is less time per frame to keep raster ahead (The Frame Budget).
  • With a very long list, tile memory becomes the constraint, and checkerboarding appears before any main-thread problem does (List Virtualization).
  • In a long-lived tab, accumulated listeners from components that never cleaned up can turn a previously smooth page into a blocked one over the course of a session (Memory Leaks).
What this costs
  • Passive listeners give up the ability to cancel the gesture. If you genuinely need to prevent a pull-to-refresh or implement a custom drag, you must block — and then the cost is real and the mitigation is to keep the listener trivially short.
  • Virtualisation makes scrolling cheap and makes find-in-page, anchor links, print and accessibility-tree completeness harder, because most of the content genuinely is not in the DOM.
  • Declarative platform mechanisms remove main-thread cost and remove control: position: sticky cannot express every header behaviour a designer might want.
  • Deferring work out of the scroll handler into a frame callback makes the effect correct and adds a frame of latency to it by construction.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALCompositor-handled scrolling, passive listener semantics and asynchronous scroll event dispatch are specified behaviour common to Blink, Gecko and WebKit; the passive default for touch and wheel listeners on document-level targets is also shared.
  • ENGINE-SPECIFICThe recovery behaviour differs: Chromium applies a timeout after which it scrolls anyway despite a pending blocking listener, WebKit on iOS drives momentum from the platform scroll machinery with different tile-ahead heuristics, and Gecko's asynchronous panning has its own checkerboarding thresholds — so identical code produces different first-frame behaviour on each.
  • DEVICE-SPECIFICTouch gestures are continuous and coupled to the finger, so one frame of latency is perceptible; wheel and trackpad input is discrete and tolerates far more, which is why a page that feels fine on a desktop can feel broken on the same site on a phone.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Computer Architecturefrequency-scaling
Domains that do not exist yet
  • Human-Computer Interaction — the perceptual thresholds behind "it followed my finger" versus "it lagged", and why continuous gestures are judged far more harshly than discrete ones.