A page of ours is prerendered HTML, a stylesheet and some text. I assumed Chrome drew it in one layer. It does not: our FAQ page runs on 10 compositing layers, and the reasons are all readable from outside with the DevTools protocol.
await page.cdp("LayerTree.enable", {}); // scroll once, because the tree is only reported when it changes const ev = await page.events(); const layers = ev.filter(e => e.method === "LayerTree.layerTreeDidChange").pop().params.layers; for (const l of layers) { const r = await page.cdp("LayerTree.compositingReasons", { layerId: l.layerId }); console.log(l.width, l.height, l.paintCount, r.compositingReasonIds); } That gives, on a 1500 x 770 window at device pixel ratio 2, so every size below is in device pixels:
| size | paints | reason | element |
|---|---|---|---|
| 0 x 0 | 0 | (none) | root |
| 3000 x 1540 | 0 | OverflowScrolling | #document |
| 3000 x 9192 | 1 | RootScroller, OverflowScrolling | #document |
| 2236 x 636 | 1 | ActiveOpacityAnimation | SPAN |
| 2560 x 816 | 1 | Overlap | DIV |
| 3000 x 80 | 1 | Overlap | DIV |
| 3000 x 140 | 0 | Overlap | DIV |
| 2832 x 308 | 27 | BackdropFilter | HEADER |
| 840 x 64 | 0 | Overlap | OL |
| 3000 x 1540 | 0 | Viewport | (none) |
Four of those are structural: the root, the viewport, and the document twice, once as the scrolling container and once as 3000 x 9192 of scrolling contents, which is the whole 4,596 CSS pixel page. Nothing to be done about those. The other six are ours.
The header, which repaints
bg-background/90 backdrop-blur-xl supports-[backdrop-filter]:bg-background/75 backdrop-blur-xl is a 24 pixel blur, and a backdrop filter cannot be drawn without a layer of its own, so Chrome promotes the header and names the reason exactly: BackdropFilter. Fine so far. The number to look at is the paint count: 27 on this capture, against 1 for every other layer that draws anything on the page. Across runs it lands between 12, for a single jump to the bottom, and 27, for a slow scroll down and back, while most of the page paints once and never again.
The reason it repaints is in the same component:
const onScroll = () => setScrolled(window.scrollY > 20); ... className={`... transition-all duration-300 ease-out ${scrolled ? "max-w-[1320px] rounded-2xl border shadow-[...]" : "max-w-full rounded-none border-b shadow-none"}`} A scroll listener flips a boolean at 20 pixels, which swaps max-width, border radius, border and shadow, and transition-all duration-300 animates every one of those over 300 ms. Max-width and padding are layout properties. So the one element on the page carrying a 24 pixel backdrop blur is also the one element whose geometry animates, and it animates in response to scrolling, which is the moment the browser is busiest.
The control is on the three legal pages, which were written before this header existed and still carry their own:
/legal/privacy 6 layers HEADER 3000x128 paint 1 reason Overlap Same sticky position, no backdrop filter, no transition, one paint. Those three pages are the only ones on the site with six layers.
The empty layer on all 79 pages
840 x 64 paint 0 Overlap OL tabindex="-1" class="fixed inset-x-0 top-0 z-[100] ..." That is the toast region, rendered by a Toaster in the root layout. The public site never raises a toast, so the element is empty: drawsContent false, paint count 0, 840 x 64 device pixels of nothing, promoted on every page because it is fixed and overlaps the composited header. It costs almost nothing, and it is on 79 pages for a feature only the signed-in dashboard uses.
And the invisible word that costs three layers
The SPAN with ActiveOpacityAnimation is the watermark at the bottom of the footer: the word "PubTrivia" at clamp(6rem, 18vw, 18rem), aria-hidden="true", and animated by framer-motion against scroll position:
const { scrollYProgress } = useScroll({ target: footerRef, offset: ["start end", "end end"] }); const y = useTransform(scrollYProgress, [0, 1], ["60%", "0%"]); const opacity = useTransform(scrollYProgress, [0, 0.3, 1], [0, 0.02, 0.05]); Its maximum opacity is 0.05. An animated opacity is exactly what the compositor wants its own layer for, so the span gets one, 2236 x 636 device pixels. Then the layer above it in paint order has to be promoted too, because it now overlaps a composited layer, and so does the one after that: the two Overlap DIVs at 2560 x 816 and 3000 x 80 are the footer's inner container and the copyright line.
The layer count therefore depends on where in the page you are:
| state | layers | what changed |
|---|---|---|
/features at the top | 7 | watermark idle, no layer |
/features after 20 scroll steps down | 10 | watermark live, plus two Overlap neighbours |
/features scrolled back to the top | 7 | watermark idle again, layers released |
/faq scrolled | 10 | same three |
/legal/privacy | 6 | no watermark in its footer at all |
That is the cleanest version of the finding. A decoration at 5 per cent opacity that no screen reader can see and no clipboard will copy adds three compositing layers to the page the moment a reader scrolls toward the footer, and gives them back when they scroll away.
The header layer changes shape, which you can see in the numbers
Two captures of /faq, one after 20 scroll steps down and one after 20 steps back up:
down: wrapper 3000x140 header 2832x308 paint 12 back: wrapper 3000x123 header 2934x178 paint 16 The header's own layer is a different size in the two captures. Scrolled down it is 2832 x 308: the narrower max-w-[1320px] rounded-2xl card, with the layer taller than the 56 pixel bar because a 0 8px 32px shadow has to be drawn too. Back at the top it is 2934 x 178, full width, square corners, no shadow. That is the geometry transition showing up directly in the compositor's layer bounds, and the paint count climbing from 12 to 16 on the way back is the cost of it. On /features the page's own scrolling-contents layer also repainted, 3 times on the way down and 5 by the time it came back.
What I would actually change
Not the layer count. Ten layers is not a problem on its own, and OverflowScrolling and RootScroller are not negotiable. The two worth touching are:
-
transition-allon the header. Transitioning four named properties instead ofall, or dropping the geometry change entirely, takes the repainting element out of the scroll path. A 24 pixel blur that redraws 27 times per visit is the most expensive thing on a page made of text. - The watermark's animation. A static 5 per cent opacity looks identical to a reader who has not noticed it in six months, and it removes four layers.
Running it on your own site
Three things to know before you try it. The tree is only reported when it changes, so the first capture needs an interaction, and there is no clean way to read the tree as it was at first paint. compositingReasons is a separate call per layer and the reason strings are the ones Chrome's own compositor uses, which are far more specific than the Layers panel shows. And every dimension is in device pixels, so halve them on a retina display before you compare anything with a CSS value. The whole audit is about 20 lines, and you can point it at any page of ours first to see what the output looks like.

