Compositing and the Layer Economy
Compositor layers make animation cheap and memory expensive. This dossier documents what promotes an element to its own layer, what that buys, and where the practice of promoting everything goes wrong.
- Level
- RESEARCHER
- Status
- ACTIVE
- Experiments
- 09
- Observations
- 18
Question
Why can a transform animation stay at 60fps while the main thread is completely blocked, and why does adding will-change: transform to a list of 500 rows make the page slower?
Background
After paint, the page is a set of display lists. The compositor turns those into textures and assembles them into a frame. Content that lives in its own layer can be moved, scaled or faded by changing a transform matrix on the GPU, with no repaint and no main-thread involvement.
| Operation | Where it runs | Main thread needed |
|---|---|---|
| transform / opacity on a composited layer | Compositor thread | No |
| scroll (in the common case) | Compositor thread | No |
| background-color change | Main thread paint | Yes |
| width / height change | Main thread layout + paint | Yes |
| non-passive touch/wheel listener | Forces main-thread scroll | Yes |
Findings
Finding 1. Every layer costs memory. A layer's texture is roughly width × height × device pixel ratio² × 4 bytes. A full-screen layer on a 3× phone is several megabytes. Hundreds of layers exhaust GPU memory and force the compositor into slower paths.
Finding 2. Promotion is not free at creation time. Promoting an element requires a separate raster pass, and un-promoting it requires the content to be re-rastered into its parent. Toggling will-change on hover can cost more than the animation saves.
Finding 3. Layer explosion is usually accidental. An element promoted for animation forces overlapping elements above it to be promoted too, so the compositor can preserve paint order. One bad promotion can cascade.
Finding 4. Scrolling is compositing. Non-passive wheel and touch listeners force scrolling back onto the main thread, because the browser must wait to learn whether the event will be cancelled. { passive: true } is the fix, and it is the default for wheel and touch listeners on the document.
Method
- 01Enable Layer borders and the Layers panel in Chrome DevTools.
- 02Record a trace while the animation runs; check whether frames appear on the compositor thread only.
- 03Count layers and read the reported memory before and after a change.
- 04Apply will-change immediately before the animation and remove it after, rather than declaring it statically.
el.style.willChange = "transform";requestAnimationFrame(() => { el.animate([{ transform: "translateX(0)" }, { transform: "translateX(240px)" }], { duration: 300, easing: "ease-out" }) .finished.then(() => { el.style.willChange = "auto"; });});