← back

Why a frozen page still scrolls

Take any page and lock its JavaScript. A while loop that spins for five seconds will do it: no click handler runs, no timer fires, no text can change, the tab is, for all the page knows, dead.

Now scroll.

It scrolls. Whatever the page was updating is stuck, the button you pressed hasn’t come back up, and the page moves under your fingers as if nothing were wrong. I measured it in Chrome, with real wheel events into a page frozen for five seconds: the first scroll began 639 ms into the freeze and every tick after it scrolled within ten milliseconds of arriving, while the code that was supposed to be running the page had not run a single line.1

Then add one line, before the freeze:

box.addEventListener("wheel", () => {}, { passive: false });

A listener that does nothing. Lock the page again, scroll again, and now nothing moves. The wheel events arrive, on time, and sit there. One millisecond after the loop ends, the whole burst lands at once.

So a page that can’t run code can scroll, and one empty function is enough to stop it. The question is what scrolling has to do with your code in the first place, and the answer is: nothing, unless you ask.

Two threads

Let’s start with who does what, because “the browser” is at least two things here.

Everything you think of as the page happens on one thread, the renderer’s main thread. It parses your HTML, resolves your CSS, lays out the boxes, paints them, and runs every line of JavaScript you wrote. It is one queue, one task at a time, and a while loop that spins for five seconds is a task that takes five seconds. Nothing else in that queue runs until it’s done. That’s the freeze.

The compositor thread is the other one. It lives in the same renderer process and owns a copy of what the main thread last produced: the page’s layers, already painted, plus a small tree of numbers that says which boxes can scroll and how far each of them has scrolled. Its job is to take that copy and put it on screen, sixty or more times a second, whether or not the main thread has anything new to say. It’s the thread that keeps a transform animation moving while your code is busy, and it’s the thread that scrolls.

Chromium’s own design page puts it in one sentence: “A key use of the compositor thread is to scroll pages smoothly even when the main thread is blocked.”2 And the code that handles input on that thread says the same thing more bluntly, in a comment: “We never scroll “on main” from the perspective of cc::InputHandler”.3

So when you turn the wheel, the path is not the one you’d draw if you’d only ever written a wheel listener. The operating system hands the event to the browser process. The browser process hands it to the renderer, and inside the renderer it goes to the compositor thread first, not the main thread. The compositor looks at its copy of the scroll tree, finds the scroller under the pointer, adds the delta to its offset, and draws the next frame from the layers it already has. Then, separately, it tells the main thread “the scroll offset is now 720”, so that the next time your code asks scrollTop it gets the right answer and a scroll event can fire. Your code hears about the scroll after it has happened, at most once per rendering opportunity.4

The path, with the main thread where it sits in it:

one turn of the wheelfour stops, and one that isn't on the way
the wheel
browser process
compositor threadadds the delta to its copy of the scroll tree, draws the next frame
screen
main threadnot on the path. Told afterwards: "the offset is now 720", so your code can read it and a scroll event can fire.

Your event listeners, layout and paint are not on that path. The frozen page scrolls because the thing doing the scrolling never needed the thing you froze. It had everything it needed before the freeze started: a picture of the page and a number to add to.

The promise

A wheel event can be cancelled. Call preventDefault() in the listener and the scroll doesn’t happen; that’s how a page implements its own zoom on ctrl-wheel, or a map that pans instead of scrolling the page. The browser has to honour that. Which means that before the compositor can scroll, it has to know whether your listener is going to cancel. And the only way to know what a function will do is to run it, on the main thread, and wait.

That wait is the whole cost. The compositor has the frame ready. It has the number. It cannot use either until a thread it doesn’t control has run a function it can’t see, and if that thread is busy, the scroll waits with it. Chromium’s enums for main-thread scrolling reasons have an entry for exactly this case, with this comment:5

Scrolling can be handled on the compositor thread but it might be blocked on the main thread waiting for non-passive event handlers to process the wheel/touch events (i.e. were they preventDefaulted?).

”Non-passive” is the word. A listener registered with { passive: true } is a listener that has promised not to call preventDefault(), and a promise is something the compositor can act on without waiting: it scrolls at once, and your listener still runs, afterwards, on the main thread, informed rather than consulted. The spec says it in one line, about touch: with passive listeners “scrolling can be allowed to start in parallel”.6 A listener without that promise, a blocking listener, is a question the compositor has to ask, and a frozen main thread is a question that never gets answered.

So the one line that stops the scroll doesn’t stop it by doing anything. It stops it by being a function the browser has to run before it knows whether it may scroll, on a thread that can’t run anything.

The box, the button and the one line:

JavaScriptrunning, tick 0
row 1
row 2
row 3
row 4
row 5
row 6
row 7
row 8
row 9
row 10
row 11
row 12
row 13
row 14
row 15
row 16
row 17
row 18
row 19
row 20
row 21
row 22
row 23
row 24
row 25
row 26
row 27
row 28
row 29
row 30
row 31
row 32
row 33
row 34
row 35
row 36
row 37
row 38
row 39
row 40
wheel listener on the box
none
box scrolled to
0 px
wheel listener
not frozen yet
Press the button, then scroll the box while JavaScript is frozen. With no listener, or a passive one, it scrolls anyway. With the blocking one, in Chrome it waits.

With no listener, or a passive one, the box moves while the bar drains and JavaScript is dead. Pick the blocking listener, freeze, scroll, and in Chrome the box waits for the bar to empty, then jumps. The box, the wheel and the frozen thread are the same. The only difference is whether the page reserved the right to say no. A finger on a phone sends a different event, and this listener does not touch it.

What waiting costs when nothing is frozen

Nobody’s page is frozen for five seconds, usually. The freeze is a way of making the wait visible. The wait itself is there on every page that has a blocking listener, every time you scroll, and it’s the main thread’s ordinary business that you’re waiting on: layout after a resize, or an analytics script that woke up on a timer.

A trace of ten wheel ticks over a plain scroller, main thread healthy: every compositor scroll update landed within one millisecond of the browser receiving the wheel event. The same ten ticks with one empty non-passive listener on the document: 1 ms, 14, 30, 48, 63.7 Each tick was queued behind whatever the main thread was doing when it arrived, and the delays stacked. A listener that does nothing had put the main thread on the critical path of every scroll.

Chrome’s engineers had the same numbers at scale, for touch, in 2016. Rick Byers, who wrote the proposal that became passive listeners, put it like this in the explainer: “in Chrome for Android 80% of the touch events that block scrolling never actually prevent it. 10% of these events add more than 100ms of delay to the start of scrolling, and a catastrophic delay of at least 500ms occurs in 1% of scrolls.” Four in five of those events were asking a question the page never acted on. One in ten of them paid a tenth of a second for it. And, in the same document: “Many developers are surprised to learn that simply adding an empty touch handler to their document can have a significant negative impact on scroll performance.”

The browsers stop asking

passive shipped in Chrome 51, in 2016, then in Firefox 49 and Safari 10.8 It was opt-in: you added { passive: true } and your scroll got faster. Byers’s explainer already described the flag as bringing “the performance properties of pointer events to touch and wheel events”, because pointer events had been designed from the start so that panning was “intentionally NOT a default action” of the event, a decision the Pointer Events spec justifies in its own words: “Removing this dependency on the cancelation of events facilitates performance optimizations by the user agent.”9 Touch and wheel events predated that thinking, and passive was the retrofit.

Opt-in wasn’t enough. Most listeners were never updated, and the 80 percent of events that never cancelled kept blocking. So in January 2017 Chrome 56 did something browsers almost never do: it changed what existing code meant. Any touchstart or touchmove listener registered on window, document or body without saying otherwise was now passive. Your preventDefault() in there stopped working, and the console told you so, in the words of Chrome’s announcement:

[Intervention] Unable to preventDefault inside passive event listener due to target being treated as passive.

Chrome called it an intervention, which is the honest word. The announcement reported the slowest one percent of scroll starts falling from about 400 ms to about 250 ms, and the intent to intervene gave the 99th percentile as 39 percent faster. Two years later, in Chrome 73, the same thing happened to wheel: 75 percent of wheel listeners didn’t specify passive and more than 98 percent of those never called preventDefault(), so the root-level ones became passive too, affecting, by Chrome’s count, less than 0.3 percent of pages.

Firefox followed, touch in 61 and wheel in 84. Safari did the same, touch in iOS 11.3 and wheel in Safari 14.1, and Apple’s release note says exactly what changed and what you have to do about it: “Wheel handlers registered on root objects (window/document/body) with default arguments will be treated as passive. Pages that want to prevent the default handling of Wheel Events which result from gestures like trackpad swipes on macOS, must now call preventDefault() on the first Wheel Event in the sequence.”10 When a WebKit engineer was asked in 2018 whether the touch change was a bug, the reply was: “This is now correct behaviour.”11

The spec caught up last. In June 2022 the DOM standard gained a “default passive value”: true for touchstart, touchmove, wheel and mousewheel when the target is the window, the document, the document element or the body, and false otherwise.12 The list has four targets, and the third, <html> itself, appears in the spec and in Blink’s and Gecko’s source and in none of the blog posts. MDN’s page for addEventListener, as I write this, says the default flips “in browsers other than Safari”. It doesn’t. All three engines do it, and Safari’s own release notes say so.

Which is why the listener in the box is on the box itself, not on the document. A wheel listener added to the document with no options has been quietly turned passive by every browser you own. To get the browser to wait for you in 2026, you have to register the listener on something other than the root, or write { passive: false } in so many words.

The older telling of the same story

Mobile browsers had run the same plot once before, with taps.

For years every tap on a phone came with a 300-to-350 ms pause. The browser was waiting to see whether a second tap was coming, because double-tap meant zoom, and it couldn’t fire click on the first tap without ruling out the second. Jake Archibald’s account from 2013 says it plainly: “Mobile browsers applied a 300-350ms delay between touchend and click while they waited to see if this was going to be a double-tap or not, since double-tap was a gesture to zoom into text.” A whole library, FastClick, existed to synthesise click from touchend and skip the wait.

The fix had the same logic as passive listeners: stop waiting for a decision that almost never comes. Chrome 32, in early 2014, dropped the delay on any page whose viewport fit the screen, since such a page has nothing to double-tap-zoom into.13 Safari did the same in iOS 9.3.14 And touch-action: manipulation let a page say it outright: no double-tap zoom here, don’t wait.

Three times, with the tap delay, then touch listeners, then wheel listeners, the browser was waiting on the page for a decision it almost never made, and three times the fix was to stop asking and let the page opt back in if it meant it.

Three engines, three clocks

The engines part ways on one question: when the page has reserved the right to cancel, and the page isn’t answering, how long do you wait?

Chrome waits. There is no timeout for a blocking wheel listener in Chromium; the wheel event queue in its source has no timeout of any kind. The wheel events sit in the browser’s wheel queue, unacknowledged, until the main thread runs the listener, and in the five-second freeze that is five seconds. There is a timeout for touch, 200 ms, but it is compiled in for Android only, with a comment that says “For historical reasons only Android enables the touch ack timeout”, and on a page with a mobile viewport it’s a full second.15

Firefox waits 400 ms. Its compositor-side scrolling, APZ, documents the rule as a deadline: content gets 400 ms on desktop and 600 on Android “to process the event and tell APZ whether preventDefault() was called”, and “if web content fails to process the event before the deadline, APZ assumes preventDefault() will not be called and goes ahead and processes the event.”16 I ran the frozen page in Firefox with the blocking listener, and the page had scrolled by the time the main thread woke up. Then I made the listener call preventDefault() and ran it again with the profiler on: the first wheel event reached the compositor, the compositor waited 402 ms, then scrolled, the full distance. By the time the listener ran and said no, it was three seconds past its deadline and the answer was ignored. Chrome, with the same listener, scrolled nothing.

Safari on macOS waits 50 ms, once. A wheel gesture here is one run of wheel events, from the first tick to the last, and WebKit’s scrolling thread waits for the main thread only on the first event of it, maxAllowableMainThreadDelay = 50_ms, and if the answer doesn’t come, the gesture is marked non-blocking and everything after the first event scrolls on the scrolling thread without asking again.17 The preference that controls it has a description that reads like the rule itself: “preventDefault() is only allowed on the first wheel event in a gesture”. That is a different design from either of the others: the page gets one chance per gesture, and a short one.

Three answers on a line:

a blocking wheel listener that isn't answeringhow long each engine waits
Chrome
waits for the listener; there is no timeout
Firefox
400 ms, then scrolls anyway
SafarimacOS
50 ms, on the first event of a gesture only; the rest of the gesture scrolls without asking
waiting for the main threadscrolling without the main thread

None of them is wrong. Chrome’s answer keeps preventDefault() meaning what it says, at the cost of a page that can hang its own scrolling. Firefox’s answer keeps scrolling alive at the cost of a preventDefault() that sometimes doesn’t work. Safari’s answer splits the difference by gesture. What they share is that the page was never asked which it wanted.

What the main thread is still for

The compositor scrolling everything is newer than it sounds. Until 2023, Chrome had two kinds of scroll: ones the compositor could do, and ones it handed to the main thread whole, because the scroller wasn’t composited or something about it needed a repaint. The project that ended that was called scroll unification, and it shipped to everyone in Chrome 115, in July 2023, after a first enable in late 2022 was reverted for crashing.18 Since then every scroll gesture is handled on the compositor, and the main thread is consulted for three separate things, which are easy to blur together and worth keeping apart.

The first is a blocking listener under the pointer: the compositor handles the gesture and the main thread holds the permission.

The second is a repaint. Some scrollers have their offset updated on the compositor but can’t show the new pixels until the main thread paints them, for example when the scroller has background-attachment: fixed inside it, or on macOS and Android when the scroller keeps subpixel text.19 The scroll happens; you don’t see it until the main thread’s next frame. Under a freeze, that scroll is accepted and invisible.

The third is a hit test, and it caught me. The compositor has to decide which scroller is under the pointer from its own copy of the page, and its copy is a stack of layers, not a DOM. Each layer is a rectangle, and the compositor tests the rectangle, not the picture painted in it. It walks the stack from front to back, collecting every layer whose rectangle contains the point, and stops at the first one that is “opaque to hit test”, meaning nothing behind it could be what you’re pointing at. If a layer in front of that one belongs to a different scroller, the compositor doesn’t guess; it asks the main thread to hit-test the DOM and waits for the answer.20

The first version of the demo box did exactly that. No listener at all, and the trace read Failed Hit Test, then Request Main Thread Hit Test, and the box waited out the freeze as if it had a blocking listener. The layer dump showed why. The row of numbers under the box, painted after it, had been merged into one layer with text painted before it, and that layer’s rectangle ran from the top of the figure to the bottom of the page. It belonged to the page’s scroller, it sat in front of the box’s own layer, and it wasn’t opaque to hit test, because a rectangle made of two things that don’t tile can’t be. So the compositor found the page’s scroller in front and the box’s scroller behind, and asked. Giving the box its own layer changed what merged with what, and that one property is the only reason the box scrolls at all while frozen.21

So “the compositor scrolls” is true, with three exceptions. The main thread is off the path unless you put a question on it, unless the pixels need painting, or unless the compositor can’t tell what you’re pointing at. The first of those is the only one a page does on purpose.

Nobody asked for any of this

There is no specification for scrolling on another thread. CSSOM View, the spec that defines scrollTop and scrollTo(), says that a programmatic smooth scroll runs “in parallel” and says nothing at all about what happens when a person turns a wheel. The HTML event loop says a scroll event is dispatched at a rendering opportunity, at most once per opportunity, and doesn’t say where the offset came from.22 The nearest the platform comes to admitting the second thread exists is a note in the DOM standard explaining why passive touch listeners are worth having, so that “scrolling can be allowed to start in parallel”, and the Pointer Events line about optimisations the user agent is free to make.

Every engine built it anyway, years apart and each in its own shape: Chrome’s compositor thread, Firefox’s APZ, WebKit’s scrolling thread on the Mac and a real UIScrollView on the phone. Every one of them decided that when your code and your finger disagree about whether the page should move, the finger wins, and then found that pages had one way left to overrule the finger, an event listener, and spent 2016 to 2021 taking that back unless the page insisted in writing.

That’s what the frozen page shows. The scroll doesn’t wait for your code because it never did; the code was never on that path. The one line that stops it is a page reserving a right the browser has been narrowing for a decade, and paying for it with every scroll, the way it always did.

Footnotes

  1. Chrome 152 on macOS, a headed window, and wheel events posted from the operating system with a small Swift program. It had to be the operating system: Chromium’s DevTools protocol waits for the page’s main thread before it will forward a wheel event (the source says “We make sure the compositor is up to date before sending a wheel event”), so Puppeteer, Playwright and ChromeDriver cannot scroll a frozen page and cannot make this measurement. Firefox has the same problem from the other side: its remote agent sets the 400 ms deadline described later to one minute for every automated session, “to 1 minute” in the source’s own comment, so WebDriver can’t see the deadline either. Times are from Chrome’s own trace, relative to a performance.mark set as the freeze began.

  2. From Chromium’s compositor thread architecture page. Parts of that page describe a “slow scroll” path that no longer exists; the sentence quoted is still true.

  3. third_party/blink/renderer/platform/widget/input/input_handler_proxy.cc, in the code that logs main-thread scrolling reasons.

  4. The HTML event loop runs “the scroll steps” for each document during “update the rendering”, once per rendering opportunity, and the spec “does not mandate any particular model” for when those come. The compositor moves pixels whenever it likes; the event is the main thread finding out.

  5. cc/input/main_thread_scrolling_reason.h, on MainThreadScrollingOtherReason::kWheelEventHandlerRegion. Since Chrome 153 the reasons are three enums rather than one; this comment sits on the third.

  6. DOM Standard, “Observing event listeners”: “non-passive TouchEvent listeners must block scrolling, but if all listeners are passive then scrolling can be allowed to start in parallel”.

  7. Chrome 152, headless, over the DevTools protocol, with a responsive main thread, ten wheel ticks over a 400 by 300 pixel overflow: auto box; times are the gap between the browser queuing the wheel event and the compositor’s scroll update in the same trace.

  8. Chrome 51 per chromestatus, with the intent to ship sent by Dave Tapuska on 18 February 2016. Firefox 49 and Safari 10 from the compatibility data; Byers wrote the explainer, Tapuska shipped it.

  9. Pointer Events, the touch-action property.

  10. New WebKit Features in Safari 14.1, April 2021. Firefox: touch in 61 (bug 1449268), wheel in 84 (bug 1673278, pref dom.event.default_to_passive_wheel_listeners).

  11. Dean Jackson in WebKit bug 182521, 6 February 2018, to a report that touchmove preventDefault() had stopped working in iOS 11.3: “touchstart and touchmove event listeners on body, document and window are now passive by default, which means they cannot preventDefault.”

  12. DOM Standard, “default passive value”, added in commit b294497 on 30 June 2022. Blink’s IsTopLevelNode() and Gecko’s IsRootEventTarget() both include the document element.

  13. John Mellor’s PSA on blink-dev, November 2013: double-tap zoom, and with it the delay, disabled on “any website whose computed viewport width … in CSS pixels is <= the window width”. Chrome 32 reached stable in January 2014. Chrome never removed the delay for all pages; the viewport rule is still the rule.

  14. Wenson Hsieh, More Responsive Tapping on iOS, December 2015, shipped in iOS 9.3: “WebKit on iOS has a 350 millisecond delay before single taps activate links or buttons”, removed for unscalable viewports and for width=device-width at initial scale.

  15. components/input/passthrough_touch_event_queue.h (200 ms for desktop sites, 1000 ms for mobile-optimised ones) and input_router_config_helper.cc for the Android-only flag. mouse_wheel_event_queue.cc has no timeout of any kind.

  16. Firefox’s Asynchronous Panning and Zooming documentation; the preference is apz.content_response_timeout, 400 in StaticPrefList.yaml and 600 in the Android prefs. Firefox 134, measured with the same wheel poster and no automation attached, timings from the Gecko profiler’s compositor thread.

  17. ThreadedScrollingTree::waitForEventToBeProcessedByMainThread in ThreadedScrollingTree.cpp: the wait runs only if wheelEvent.isGestureStart(), and on timeout “go asynchronous”. The scrolling thread exists on macOS only; iOS scrolls in the UI process with UIScrollView. The preference is WheelEventGesturesBecomeNonBlocking. Not measured here; Safari’s automation needs enabling by hand and I read the code instead.

  18. Steve Kobes’s Scroll Unification design document, December 2021, and its December 2023 update: “Scroll Unification launched to all platforms in M115, which was released to the stable channel in Jul 2023.” The first enable, in Chrome 108, was reverted in 111 because it “is causing a significantly higher crashrate in stable”.

  19. Chromium’s MainThreadRepaintReason: kHasBackgroundAttachmentFixedObjects, kNotOpaqueForTextAndLCDText, kPreferNonCompositedScrolling, kBackgroundNeedsRepaintOnScroll. The scroll offset is updated on the compositor and the pixels wait for a commit; Kobes’s document: “the user won’t see the new pixels until the main frame’s lifecycle repaints the scroller’s content at the new offset.”

  20. LayerTreeImpl::FindLayersUpToFirstScrollableOrOpaqueToHitTest in cc/trees/layer_tree_impl.cc does the walk, testing each layer’s bounds(), and InputHandler::IsInitialScrollHitTestReliable in cc/input/input_handler.cc returns false when a layer in front of the first opaque one would scroll a different node. The reason is recorded as MainThreadHitTestReason::kFailedHitTest, bucket 9 of the Renderer4.MainThreadWheelScrollReason2 histogram, which you can read on chrome://histograms. “Opaque to hit test” is cc::HitTestOpaqueness; a border radius makes a box “mixed” on its own, and two opaque rectangles whose union isn’t a rectangle make a mixed layer.

  21. will-change: transform on the box, which gives it a directly composited transform node, which changes which paint chunks merge into which layers. I tried will-change: scroll-position first and it changed nothing; in Chromium that property only expresses a preference for composited scrolling, and on a Mac, where there is no subpixel text to protect, every scroller already gets it.

  22. CSSOM View for scroll() and scrollTo(), whose smooth-scroll steps run “in parallel”; the HTML event loop for “update the rendering” and rendering opportunities.