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. As far as the page knows, the tab is dead.
Now scroll.
It scrolls. Whatever the page was updating is stuck, the button you pressed hasn’t come back up, and the page still moves under your fingers as if nothing were wrong.
Then add one line before the freeze:
box.addEventListener("wheel", () => {}, { passive: false });
It’s a listener that does nothing. Lock the page again, scroll again, and now nothing moves. The wheel events arrive on time and just sit there, and a millisecond after the loop ends, the whole burst lands at once.
I measured the first case in Chrome, with real wheel events sent from the operating system into a page frozen for five seconds. The page started scrolling the moment the first wheel event arrived, and every tick after that moved it within ten milliseconds, while not one line of the page’s own code ran.1
So a page that can’t run any code can still scroll, and one empty function is enough to stop it. What does scrolling have to do with your code in the first place?
Let’s start with who does what, because “the browser” is at least two things here.
Chrome runs web pages in renderer processes, separate from the browser process that owns the window and gets your input. Everything you think of as the page happens on one thread in there, 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’s 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, and that’s the freeze.
The compositor thread is the other one. It lives in the same renderer process and keeps a copy of what the main thread last produced: the page’s layers, already painted (think of them as flat painted sheets it can slide around and stack without painting them again), plus a small tree of numbers saying which boxes can scroll and how far each one has scrolled. Its job is to put that copy 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 says it exists partly for exactly this, so that pages keep scrolling smoothly while the main thread is blocked.2
So when you turn the wheel, the event doesn’t take the path you’d draw if you’d only ever written a wheel listener. The operating system hands it 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 wheel’s delta to its offset, and draws the next frame from the layers it already has. Only then does it tell the main thread “the scroll offset is now 720”, so that the next time your code reads scrollTop it gets the right answer and a scroll event can fire. Your code hears about the scroll after it’s happened, at most once a frame.3
Laid out end to end, the path doesn’t touch the main thread:
Your event listeners, layout and paint aren’t on that path. The compositor never needed the main thread for this. It had everything it needed before the freeze started: a picture of the page and a number to add to.
So why does one empty listener stop all of that? Because a wheel event can be cancelled. Call preventDefault() in the listener and the scroll doesn’t happen, which is how a page does its own zoom on ctrl-wheel, or how a map pans instead of scrolling the page. The browser has to honour that, so 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 and it has the number, but it can’t use either until the main thread has run your listener, and if the main thread is busy, the scroll waits with it. Chromium even has a named reason for this in its source, one of the ways a scroll can end up blocked on the main thread.4
A listener registered with { passive: true } has promised not to call preventDefault(), and the compositor can act on a promise without waiting: it scrolls straight away, and your listener still runs on the main thread, just after the page has already moved. A listener without that promise is called a blocking listener. For every wheel event, the compositor has to ask the main thread about it, and a frozen main thread never answers.
So the one line doesn’t stop the scroll by doing anything. It stops it by being a function the browser has to run before it knows whether it’s allowed to scroll, on a thread that can’t run anything right now.
You can try it yourself: a box, a button that freezes JavaScript, and the one line:
Press the button and JavaScript freezes for five seconds. The tick counter in the demo’s header stops, because JavaScript is what counts it, while the bar on the button keeps draining, because it’s a compositor animation. Scroll the box while the bar drains. With no listener, or a passive one, it moves. Pick the blocking listener and do it again, and in Chrome the box waits for the bar to empty, then jumps. Firefox and Safari don’t wait nearly as long; they give up on the listener after a deadline.
On a phone none of this shows up, because a finger sends touch events, not wheel events, and this listener only listens for wheel. Touch has the same rule, though, and it’s where the whole story of passive listeners started.
So is the main thread off the path for good, as long as you don’t add a blocking listener? Not quite, and the demo box is how I found out.
Some of this is recent. Until 2023, Chrome had two kinds of scroll: ones the compositor did, and ones it handed to the main thread whole, because the scroller wasn’t on a layer of its own or something about it needed repainting. Most scrolling was on the compositor long before that, which is why a blocking listener was worth fighting over in 2016, but the rest only moved over with a project called scroll unification. It shipped to everyone in Chrome 115, in July 2023, after a first attempt in late 2022 was rolled back for crashing.5 Since then every scroll gesture is handled on the compositor, and the main thread only gets asked about three things.
The first is the blocking listener you’ve already seen: the compositor handles the gesture, but the main thread holds the permission.
The second is a repaint. Some scrollers get their new offset on the compositor but can’t show the new pixels until the main thread paints them, for example when there’s something with background-attachment: fixed inside the scroller.6 The scroll happens, you just don’t see it until the main thread’s next frame, so under a freeze it’s accepted and invisible.
The third is a hit test, which means working out which scroller is under the pointer. The compositor has to do that from its own copy of the page, and that copy is a stack of layers, not a DOM. It checks each layer’s rectangle rather than what’s painted in it, front to back, and stops at the first one solid enough that nothing behind it could be the target. If a layer in front of that one belongs to a different scroller, it doesn’t guess: it asks the main thread to hit-test the DOM, and waits for the answer.7 This one caught me while I was building the demo. Without a layer of its own, the box waited out the whole freeze with no listener on it at all.8
So “the compositor scrolls” is true, with three exceptions: the main thread is on the path if you put a question on it, if the pixels need painting, or if the compositor can’t tell what you’re pointing at. Only the first of those is something your code asked for.
Nobody’s page is frozen for five seconds, usually. The freeze just makes the wait easy to see. The wait itself is there on every page with a blocking listener, every time you scroll, and what you’re waiting on is the main thread’s ordinary business: layout after a resize, or an analytics script that woke up on a timer.
In a trace of ten wheel ticks over a plain scroller with a healthy main thread, every compositor scroll update landed within a millisecond of the browser getting the wheel event. Add one empty listener on the document with { passive: false }, and the scroll updates came out at 1, 14, 30, 48 and 63 ms, roughly one per frame, because now each one had to go through the main thread first.9 Nothing was frozen and the listener did nothing, and it still put the main thread in front of every scroll.
Chrome’s engineers had seen the same thing at scale, for touch, back in 2016. Rick Byers, who wrote the proposal that became passive listeners, counted it in the explainer: in Chrome for Android, 80 percent of the touch events that blocked scrolling never actually cancelled it, 10 percent of them added more than 100 ms before scrolling could start, and one scroll in a hundred was held up by at least half a second.
passive shipped in Chrome 51 in 2016, then in Firefox 49 and Safari 10.10 It was opt-in: you added { passive: true } and your scrolling got faster. The idea came from pointer events, the newer event API that covers mouse, pen and touch in one, which were designed from the start so that scrolling never waits on whether an event gets cancelled.11 Touch and wheel events are older than that, and passive was the retrofit.
Opt-in wasn’t enough, though. 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 on window, document or body that didn’t say otherwise was now passive. If you called preventDefault() in one, it 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.
The announcement reported the slowest one percent of scroll starts dropping from about 400 ms to about 250 ms. Two years later, in Chrome 73, the same thing happened to wheel: 75 percent of wheel listeners didn’t say whether they were passive, and more than 98 percent of those never called preventDefault(), so the root-level ones became passive too. By Chrome’s count that affected 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, with one escape hatch in Apple’s release notes: a page that wants to stop a trackpad swipe on macOS has to call preventDefault() on the first wheel event of the gesture.12 When someone reported the touch change to WebKit as a bug in 2018, the answer was that this was the correct behaviour now.13
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 listener is on the window, the document, the document element or the body, and false everywhere else.14
That’s why the listener in the demo 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 use. To get the browser to wait for you in 2026, you have to put the listener on something other than the root, or write { passive: false } in so many words.
The engines part ways on one question: when the page has kept the right to cancel and isn’t answering, how long do you wait?
Chrome waits. Chromium has no timeout for a blocking wheel listener; its wheel event queue has no timeout of any kind. The wheel events sit there unacknowledged until the main thread runs the listener, which in the five-second freeze means five seconds. (There is a timeout for touch, but it’s only switched on for Android.15)
Firefox waits 400 ms. Its version of compositor scrolling is called APZ, and APZ’s documentation describes the rule as a deadline: the page gets 400 ms on desktop and 600 on Android to handle the event and say whether it called preventDefault(), and if it misses that, APZ assumes it didn’t and scrolls.16 I ran the frozen page in Firefox with the blocking listener, and the page had already 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, and then it 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, didn’t scroll at all.
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 (Safari’s version of the compositor thread) waits for the main thread only on the first event of it: maxAllowableMainThreadDelay = 50_ms in the source. If the answer doesn’t come in time, the rest of the gesture scrolls without asking again.17 So the page gets one chance per gesture, and a short one.
Side by side, the three engines give a page that isn’t answering very different amounts of time:
I don’t think any of them is wrong. Chrome keeps preventDefault() meaning exactly what it says, and the price is that a page can hang its own scrolling. Firefox keeps scrolling alive, and the price is a preventDefault() that sometimes doesn’t work. Safari gives the page a quick say at the start of each gesture and then stops asking.
There’s no specification for scrolling on another thread. CSSOM View, the spec that defines scrollTop and scrollTo(), says a smooth scroll you start from code runs “in parallel”, and says nothing about what happens when a person turns a wheel. The HTML event loop says when a scroll event fires, not where the offset came from.18 The closest the platform gets to admitting the second thread exists is a note in the DOM standard about passive touch listeners, which lets scrolling “start in parallel”.19
Every engine built one anyway, years apart and each in its own shape: Chrome’s compositor thread, Firefox’s APZ, WebKit’s scrolling thread on the Mac, and on the iPhone the same native scroll view iOS apps use. All of them decided that when your code and your finger disagree about whether the page should move, the finger wins. Then they found pages still had one way to overrule the finger, an event listener, and between 2016 and 2021 they took that back too, unless the page insisted in writing.
So what does scrolling have to do with your code? Almost nothing. The compositor scrolls from a copy of the page it already has, and your code finds out afterwards. The main thread still gets pulled in for a repaint or a hit test it can’t avoid, but the one way your code gets on the path is a listener that might say no, and that listener costs you on every scroll, frozen page or not.
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 APZ’s 400 ms deadline 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. ↩
From Chromium’s compositor thread architecture page. Parts of that page describe a “slow scroll” path that no longer exists; the part about scrolling while the main thread is blocked is still true. ↩
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. ↩
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: “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?).” ↩
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”. ↩
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.” ↩
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. ↩
With no listener at all 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 row of numbers under 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, sat in front of the box’s own layer, and wasn’t opaque to hit test, because a rectangle made of two things that don’t tile can’t be. So the compositor found one scroller in front and another behind, and asked. The fix was will-change: transform on the box, which gives it a directly composited transform node and so 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. ↩
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. ↩
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. ↩
Pointer Events, the touch-action property. ↩
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). ↩
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.” ↩
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, which is the target the blog posts all leave out. MDN’s page for addEventListener says the default flips “in browsers other than Safari”; it doesn’t, all three engines do it, and Safari’s release notes say so. ↩
The touch timeout is compiled in for Android only, with the comment “For historical reasons only Android enables the touch ack timeout”. 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. ↩
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. ↩
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. ↩
CSSOM View for scroll() and scrollTo(), whose smooth-scroll steps run “in parallel”; the HTML event loop for “update the rendering” and rendering opportunities. ↩
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”. ↩