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.
Why does scrolling stay smooth on a busy page sometimes and stutter badly other times, and what decides which?
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.
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.
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.
- 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,touchmoveorwheellistener 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
scrolllistener 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: fixedheaders, 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).
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 callpreventDefault(), 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
scrolllistener 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
documentfor 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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
Non-passive touchmove or wheel listener in the event region | Content lags the finger, worst when the app is busy | The compositor must wait to learn whether the gesture will be cancelled | Add { passive: true }; if cancelling is genuinely needed, scope the listener to the smallest element and keep it trivial (Passive Listeners). |
| Scroll handler reading layout | Stutter proportional to page complexity | Forced synchronous layout inside the handler, once per event | Record the offset in the handler; do the reads and writes in one requestAnimationFrame callback (Layout Thrashing). |
| Parallax or scroll-linked animation in JavaScript | The effect visibly trails the scroll | The visual output depends on a main-thread computation that runs after the compositor has already moved | Use position: sticky, IntersectionObserver, or scroll-driven animations where available; accept a frame of lag otherwise. |
| Third-party analytics or chat widget | Scrolling degrades after the widget loads | A document-level blocking listener registered by code you do not control | Find 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 paint | Blank or checkerboarded regions on fast flicks | Raster cannot keep tiles ahead of the scroll within its memory budget | Virtualise, apply content-visibility to off-screen sections, and simplify paint on the scrolling surface (content-visibility). |
| Custom wheel-driven smooth scrolling | Feels wrong on trackpads, breaks keyboard scrolling and screen-reader scrolling | Platform scroll behaviour has been replaced by a main-thread approximation | Delete it. Use scroll-behavior: smooth if smoothness is the goal, and honour reduced motion. |
The latency the user actually feels
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.
- Long task already running — Runs 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 + handler — The part most people optimise. It is usually the smallest bar.
- Pixels the user sees — The 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.
window.addEventListener('scroll', () => {
// forced synchronous layout, once per scroll event
const top = content.getBoundingClientRect().top
header.classList.toggle('is-stuck', top < 0)
})// 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.
1let latest = 02let queued = false3 4window.addEventListener(5 'scroll',6 () => {7 latest = window.scrollY // a read the browser already has: no layout forced8 if (queued) return9 queued = true10 requestAnimationFrame(() => {11 queued = false12 applyEffect(latest) // runs at most once per frame13 })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 ondocument(Passive Listeners). - Do nothing expensive in a
scrolllistener. Record the offset, and do the work in arequestAnimationFramecallback so it happens once per frame rather than once per event. - Prefer declarative platform mechanisms over scroll listeners entirely:
position: stickyfor headers,IntersectionObserverfor "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-visibilityon 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
tabindexand 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-motioncovers 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
- A third-party script registering a blocking
touchstartlistener ondocument, 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.
- 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
scrollToissued 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).
- 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).
- "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.
- "
scrollevents 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
- 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).
- 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).
- 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: stickycannot 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.
- — 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.