Layers and compositing.
Painting is expensive and moving is cheap, so browsers try not to paint twice. They draw some parts of the page into separate bitmaps, called layers, and hand those to a compositor thread that slides, fades and scrolls them on the GPU, frame after frame, while JavaScript is still busy elsewhere. That one arrangement explains why a transform animation keeps gliding through a long task, why scrolling rarely freezes, and why a stylesheet that promotes everything can make a phone run out of memory.
Builds on From bytes to pixels and The frame and the event loop.
The idea.
Moving a picture is cheaper than drawing it again. Browsers exploit that by keeping some parts of the page as separate pictures, layers, and letting another thread shuffle them into each frame.
Take Copperpot's product page for the 24 cm enamel casserole. A shopper taps Add to basket, and an "Added to basket" bar should rise from the bottom of the screen over 250 ms. In the same instant the page starts hydrating its reviews widget, a 180 ms burst of JavaScript on the main thread. If the bar's motion depends on the main thread, it simply stops: no frame can be produced while the script runs, the bar sits half hidden, then jumps to its end. Shoppers read that as a broken page.
Browsers avoid that by splitting the work in two. The main thread works out what everything looks like and records how to draw it. Some parts of the page are drawn into their own bitmaps, layers, instead of into the page's main one. A second thread, the compositor, holds those layers and decides each frame where each one sits, how transparent it is and how far the page has scrolled, then asks the GPU to stack them. Changing those few properties needs no new drawing, so the compositor can do it alone, sixty or more times a second, whatever JavaScript is doing.
That is the whole contract. If the bar moves with transform, it is a layer slid by the compositor and keeps gliding through the reviews task. If it moves with bottom, every frame needs a new layout and a new paint, which only the busy main thread can do. Layers are not free, though: every one is a bitmap held in GPU memory, so the craft is choosing which parts deserve one.
Copperpot's product page as the compositor sees it
Which changes the compositor can make on its own
| Change on Copperpot's page | What it touches | Needs the main thread each frame? |
|---|---|---|
| Page scrolls 640 px | The scroll offset of the root scroller | No, unless a non-passive wheel or touch listener has to be asked first |
| Gallery swipes to photo 2 | The scroll offset of the gallery's scroll-snap scroller | No; the photos are already painted and the compositor scrolls them |
| Basket bar slides up and fades in | Its transform and opacity | No, once the animation has been handed to the compositor |
| Basket bar slides up by animating bottom | The bar's position in layout | Yes: style, layout and paint every frame |
| Basket count changes from 2 to 3 | Text inside the header | Yes, once: the header is repainted and re-rastered, then composited |
| Swatch turns from Cream to Teal | The colour of pixels inside the page layer | Yes, once: paint and raster, no layout |
How it works.
Follow Copperpot's page from the end of paint to a frame on the glass: how the browser cuts the painted page into layers, what earns a part its own layer, how layers become tiles, and what the compositor does each frame without the main thread. Chromium's names are used throughout, because its pipeline is the one documented in the most detail.
From a display list to a layer list
Paint does not make pixels. It walks the laid-out page in paint order and records drawing commands: fill this rectangle cream, draw this run of text, draw this decoded photo here. Each command is tagged with the visual context it is drawn in, and in Chromium that context is a position in four property trees. Consecutive commands that share a context are grouped into a paint chunk.
The property trees are what make compositing possible. A transform, a clip, an opacity or a scroll offset is not baked into the drawing commands; it sits in a tree node that the commands point to. Change the node and every chunk under it moves, fades or scrolls, with the commands themselves untouched. The compositor thread keeps its own copy of those trees, so it can change a node between commits and draw the next frame without asking the main thread.
Layerization then decides which chunks share a bitmap. Since Composite After Paint shipped in Chromium M94, this step runs after paint, on the compositor side of the commit, with the full painted result in hand. Merging chunks into one layer saves GPU memory; keeping a chunk apart means it can move without the layer under it being drawn again. Chromium splits where it expects a property to change on the compositor: a scroller, a running transform or opacity animation, a will-change hint.
The four property trees, on Copperpot's page
| Tree | What its nodes hold | A node on the casserole page |
|---|---|---|
| transform | 2D or 3D matrices, and the offset of every scroller (scrolling is a translation) | The basket bar's translateY; the root scroller's 640 px offset; the gallery's sideways offset |
| clip | Overflow clips and clip rectangles | The gallery scroller's box that hides photos 2 to 5 |
| effect | Opacity, filters, masks, blend modes | The basket bar's opacity while it fades in |
| scroll | Which boxes scroll, their sizes and how a scroll chains to the parent at its end | The root page and the gallery |
What earns a part of the page its own layer
Common reasons, and whether you asked for them
| Reason | Example | Direct or side effect |
|---|---|---|
| A hint that a property is about to change | will-change: transform / will-change: opacity | Direct; you asked |
| A running transform or opacity animation | bar.animate([{ transform: 'translateY(100%)' }, …]) | Direct; for the length of the animation |
| A 3D transform or perspective | transform: translateZ(0) | Direct; the old trick, now better written as will-change |
| Content drawn outside the page's own paint | <video>, <canvas> with WebGL, an <iframe> | Direct; the browser decides |
| A scroller the compositor scrolls | overflow-x: auto on the gallery | Direct, when the browser composites the scroller |
| Fixed or sticky content over a composited scroll | position: sticky on the header | Usually; it must move against the scrolled content |
| Overlapping a composited layer and painting above it | the "Free delivery" badge over the gallery | Side effect: it needs a layer too, or it would be drawn under the gallery |
The last row is the one that surprises people. The compositor stacks layers in a fixed order, so anything that paints on top of a composited layer and overlaps it cannot stay inside the page layer underneath; it would end up hidden. The browser has to give the overlapping content a layer too. Put will-change on one element early in paint order and later siblings that overlap it get pulled up with it. Chromium limits the damage by squashing several overlapping elements into one shared layer, but a careless hint can still multiply the layer count.
Two side effects travel with will-change and with a real transform. The element becomes a stacking context, so a z-index inside it no longer competes with the rest of the page (stacking has its own topic). And will-change: transform makes the element the containing block for its position: fixed descendants, so a fixed close button inside a promoted panel now sticks to the panel, not to the screen. The CSS Will Change specification requires both: the hint must have the layout effects the property itself would have.
Tiles, raster and the checkerboard
A layer can be far bigger than the screen: Copperpot's gallery scroller is five photos wide, about 1,965 CSS px. The compositor cuts every layer into tiles and has them rastered (turned from drawing commands into pixels) a tile at a time, by raster workers that in current Chromium run in the GPU process. Tiles in the viewport go first, then tiles just outside it. Tiles far away may never be rastered at all.
This is why scrolling is cheap and also why it can go blank. While the shopper swipes at a normal pace, the next photo's tiles are ready before they slide in. A hard fling can outrun raster: the compositor still moves the layer on time, because it never waits for pixels, but tiles that arrive on screen unrastered are drawn as a plain background (the checkerboard) for a frame or two until raster catches up. A smooth scroll with a gap is a trade the compositor makes on purpose.
The gallery scroller as tiles, swipe by swipe
- photo 1, 1 item, On screen and rastered
- photo 1, 1 item, On screen and rastered
- photo 2, 1 item, Rastered, off screen
- photo 2, 1 item, Rastered, off screen
- photo 3, 1 item, Not rastered
- photo 3, 1 item, Not rastered
- photo 4, 1 item, Not rastered
- photo 4, 1 item, Not rastered
- photo 5, 1 item, Not rastered
- photo 5, 1 item, Not rastered
- viewport, from 0 to 2
- On screen and rastered
- Rastered, off screen
- Queued for raster
- Not rastered
- On screen, blank (checkerboard)
As it starts. 4 steps follow.
One frame while the main thread is busy
A gallery swipe during the reviews task
- Note over Renderer main thread: running the reviews widget task (180 ms)
- Shopper's finger → Compositor thread: touchmove: 24 px to the left
- Note over Compositor thread: no blocking touch listener here: no need to ask the main thread
- Note over Compositor thread: gallery offset += 24 px in its copy of the scroll and transform trees
- Compositor thread → Raster and decode threads: raster photo 3 tiles (now near the viewport)
- Raster and decode threads → GPU process (Viz) (reply): tile textures
- Compositor thread → GPU process (Viz): compositor frame: same tiles, new offset
- Note over GPU process (Viz): frame on screen within the 16.7 ms budget
- Compositor thread → Renderer main thread: scroll event, queued until the task ends
- Renderer main thread → Compositor thread (reply): next commit: updated display lists, if anything was repainted
This is threaded scrolling: the compositor turns finger and wheel input into a new scroll offset itself. It can only do that when nothing on the main thread could change the outcome. A scroll listener cannot; the scroll event is not cancelable and fires after the page has already moved. A touchstart, touchmove or wheel listener can, because calling preventDefault on those cancels the scroll. So over the area such a listener covers, the compositor has to send the event to the main thread and wait for the answer before it moves anything (Chromium calls that area a non-fast scrollable region). If the reviews task is running, the swipe waits up to 180 ms.
A passive listener is a promise not to call preventDefault. With { passive: true } the compositor scrolls at once and hands the event to the listener whenever the main thread is free; a preventDefault inside it is ignored. Browsers already treat touchstart, touchmove and wheel listeners on window, document and body as passive by default, so the trap is a listener on an element such as the gallery. When a component really needs to stop some gestures, the CSS touch-action property says which ones up front (touch-action: pan-y lets the browser scroll vertically and leaves sideways drags to script), so the compositor knows without asking. The full listener options belong to the events topic. The same logic applies to script-driven motion: a drag that sets transform from a pointermove handler moves no faster than the main thread delivers those handlers, even though the transform itself is cheap.
A swipe tracker on the gallery that does not hold up scrolling
// Non-passive: every touchmove over the gallery must reach the
// main thread before the compositor may scroll it.
gallery.addEventListener('touchmove', trackSwipe);
// Passive: the compositor scrolls first; trackSwipe runs later,
// when the main thread is free, and cannot cancel the scroll.
gallery.addEventListener('touchmove', trackSwipe, { passive: true });
The same slide-up on three routes
Which stages a change to each property runs again
| Property animated | Layout | Paint | Composite |
|---|---|---|---|
| width, height, top, left, bottom | Yes, every frame | Yes, every frame | Yes |
| background-color, box-shadow, color | No | Yes, every frame | Yes |
| transform, opacity | No | No (once the layer is rastered) | Yes, on the compositor thread |
| filter (some functions, some engines) | No | Depends on the engine | Yes, where the engine composites it |
The basket bar's 250 ms slide-up, with and without the main thread
Scenario 1 of 3: As described.
Timeline as a list
The basket bar's 250 ms slide-up, with and without the main thread: 4 lanes, from 0 ms to 300 ms.
- 0–6 ms · Main thread · tap: start animation, paint bar
- 6 ms · Main thread → Compositor thread, arriving 8 ms: commit
- 8–258 ms · Compositor thread · samples transform + opacity per frame
- 8–258 ms · GPU process · a frame every 16.7 ms
- 8 ms · Screen · bar starts rising
- 12–192 ms · Main thread · reviews widget hydrates
- 258 ms · Screen · bar in place (ok, ok)
The two versions of Copperpot's basket bar
.basket-bar {
position: fixed;
left: 0;
right: 0;
bottom: -120px; /* hidden below the screen */
transition: bottom 250ms ease-out;
}
.basket-bar.is-open {
bottom: 0; /* layout + paint every frame */
}
.basket-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0; /* layout never changes */
transform: translateY(100%);
opacity: 0;
transition: transform 250ms ease-out, opacity 250ms ease-out;
}
.basket-bar.is-open {
transform: translateY(0); /* compositor only */
opacity: 1;
}
function openBasketBar(bar) {
// Render one frame with the hint, so the layer exists before the motion starts.
bar.style.willChange = 'transform, opacity';
requestAnimationFrame(() => {
requestAnimationFrame(() => bar.classList.add('is-open'));
});
bar.addEventListener('transitionend', function settle(event) {
if (event.propertyName !== 'transform') return; // opacity ends too
bar.style.willChange = 'auto'; // let the layer go when it rests
bar.removeEventListener('transitionend', settle);
});
}
A layer's life
The basket bar, from part of the page to its own layer and back
States of2Compositor thread
What the browser holds for the bar at each moment. Moving it never needs a repaint; changing what is inside it always does, layer or no layer. Removing the hint lets the bar merge back into the page layer on a later commit.
| From → To | Event | Action | Actor |
|---|---|---|---|
| Painted into the page layer → Own layer, not rastered yet | will-change set | layerize on next commit | main thread |
| Own layer, not rastered yet → Own layer, tiles ready | tiles rastered | raster | |
| Own layer, tiles ready → Moving on the compositor | transition starts | compositor owns it | compositor |
| Moving on the compositor → Moving on the compositor | vsync | new transform and opacity | compositor |
| Moving on the compositor → Own layer, tiles ready | transitionend | compositor | |
| Own layer, tiles ready → Content changed | "3 items" becomes "4 items" | main thread | |
| Content changed → Own layer, not rastered yet | paint and commit | re-raster the bar only | main thread |
| Own layer, tiles ready → Painted into the page layer | will-change auto | merged back on a later commit | main thread |
- Painted into the page layerstart
- No bitmap of its own; drawn with everything around it
- Own layer, not rastered yet
- Layerize gave it a layer; tiles are queued
- Own layer, tiles ready
- Can be moved or faded at no paint cost
- Moving on the compositor
- translateY and opacity change every frame
- Content changed
- Its display list is stale; the main thread must repaint it
In practice.
On Copperpot's product page, which parts deserve a layer, what each one costs in memory, how to check what the browser actually did, and how to say all of this in a front-end interview in a couple of sentences.
The casserole page and its moving parts
Two layers beyond the page itself: the sticky header (it must stay put against the scrolled page) and the gallery scroller. The basket bar has no layer until it is about to move.
At rest. Store “Copperpot”, showing the product view. Cart button “Basket” with badge 2 (its accessible name says “Cart, 2 items”, not the number alone). Product (showing) “Enamel casserole, 24 cm” by “Copperpot Kitchen”: rating 4.8 (“312 reviews”); price £64.00. Gallery: image 1 of 5 (“Enamel casserole in cream, lid on”); thumbnails beside it. Colour (swatches, a radio group): Cream (selected), Teal, Rust. Quantity 1 (stepper “Quantity”). Buttons: “Add to basket”. Delivery: “Delivered Thursday”. Folded sections: Details, Reviews, Care and cleaning. Note on header: sticky: own layer, moves against the scroll Note on gallery: scroll-snap scroller: own layer, tiled
- Composited scroller2Compositor thread
- Basket bar layer2Compositor thread
- Tap handler1Renderer main thread
What each layer costs Copperpot's phone
- Device pixels per CSS pixel
- 3assumption; a common phone ratio
- Bytes per rastered pixel
- 4RGBA, 8 bits each; before any GPU compression
- Viewport
- 393 × 852 CSS pxassumption
- One gallery photo
- 393 × 393 CSS px
- Basket bar
- 393 × 120 CSS px
- One review card
- 393 × 160 CSS px36 cards on the page
- A viewport-sized layer(393 × 3) × (852 × 3) × 4 = 1,179 × 2,556 × 412.1 MBfrom Device pixels per CSS pixel, Bytes per rastered pixel and Viewport
- One photo1,179 × 1,179 × 45.6 MBfrom Device pixels per CSS pixel, Bytes per rastered pixel and One gallery photo
- The whole gallery if every tile were rastered5 × 5.6 MB27.8 MBfrom One photo · Raster near the viewport only keeps the real figure well below this
- The basket bar while it is a layer1,179 × 360 × 41.7 MBfrom Device pixels per CSS pixel, Bytes per rastered pixel and Basket bar
- will-change on every review card36 × (1,179 × 480 × 4) = 36 × 2.26 MB81.5 MBfrom Device pixels per CSS pixel, Bytes per rastered pixel and One review card
- The memory follows area, multiplied by the square of the pixel ratio: the same card costs 9 times as much at DPR 3 as at DPR 1.
- A layer that is promoted only while it moves, like the basket bar, costs 1.7 MB for a quarter of a second. A hint left on 36 cards that never move costs about 81 MB for as long as the page is open.
- Tiling keeps the real cost of big layers below the worst case, but it cannot help when many layers sit in the viewport at once.
GPU memory per layer choice on Copperpot's page
Data
| Layer | MB |
|---|---|
| Basket bar | 1.7 |
| One photo | 5.6 |
| Viewport-sized layer | 12.1 |
| Whole gallery | 27.8 |
| 36 review cards | 81.5 |
Main-thread time per frame of the basket bar's slide-up
Data
| Property animated | ms per frame |
|---|---|
| bottom (layout route) | 5 |
| background-color (paint route) | 2.4 |
| transform + opacity (composite route) | 0 |
- budget for page work: Main-thread work per frame (ms) = 10
Checking what the browser actually did (Chrome DevTools)
| Tool | What it shows | What you want to see on Copperpot |
|---|---|---|
| Rendering → Paint flashing | Green flashes wherever the page is repainted | Nothing flashes while the gallery swipes or the bar slides; only the header flashes when the count changes |
| Rendering → Layer borders | Outlines around layers and their tiles | Borders around the header, the gallery and, briefly, the bar; none around review cards |
| Layers panel | Every layer with its size, compositing reasons, memory estimate and paint count | A handful of layers, a few MB each; the bar's reason is will-change or an active animation, and its paint count stays at 1 while it moves |
| Performance panel | Main-thread tasks next to the frames that were drawn | Frames keep arriving through the 180 ms reviews task |
| Rendering → Scrolling performance issues | Elements whose listeners can slow scrolling | No touch or wheel listener over the gallery that is not passive |
Not only Chromium
Layer promotion, squashing and the Layers panel are Chromium's machinery, and the details above are its details. Safari also composites layers on the GPU with its own rules for when to make one. Firefox's WebRender takes a different route: it draws the page's display list on the GPU every frame rather than keeping many painted bitmaps, so a misplaced will-change costs it less and a layer count means little there. What holds everywhere is the part an interview cares about: transform and opacity changes can be applied without layout or paint and away from the main thread, and geometry changes cannot.
Where the system flows lean on it
Saying it in an interview
Trade-offs.
A layer trades memory and upload time for the freedom to move without repainting. Three decisions follow from that, and a handful of ways it goes wrong.
- Pro:The layer exists for the first frame of the motion
- Pro:Memory is held for a quarter of a second
- Pro:No permanent stacking-context side effects on a resting element
- Con:A little code to add and remove the hint
- Con:Adding or removing a hint has a one-time cost of its own
Holds GPU memory for the life of the page; Permanent stacking context and containing block for fixed children; Spreads by copy-paste to elements that never move
The first frame can be late while the layer is made and rastered; Does not help a script-driven change
- Pro:Composite route: no layout or paint per frame
- Pro:Keeps moving through main-thread tasks
- Pro:Works across engines
- Con:Layout around the element does not follow it
- Con:Scaling text can look soft until it is rastered again
Layout and paint every frame on the main thread; Freezes during long tasks
Repaint and re-raster every frame; Still waits for the main thread
- Pro:Memory stays near one screenful
- Pro:Cheap to keep rastered
- Con:Changing one card repaints its tiles of the shared layer
Memory grows with the number of cards (81 MB for 36 on Copperpot); More layers to upload and manage every frame; Overlap can pull in neighbours
What goes wrong
| Failure | Impact | Detection | Mitigation | Meanwhile |
|---|---|---|---|---|
| Layer explosion4GPU process (Viz) | Hundreds of layers, from will-change in a shared class or from overlap with one promoted element; tiles are evicted and re-rastered, frames are late, a low-memory tab may be reloaded | Layers panel count and memory estimate; compositing reason 'overlaps' on many layers | Remove blanket hints; promote only what moves, while it moves; give the moving element a higher stacking order than its neighbours | Animations still run, but scroll and raster fall behind |
| Blank tiles in a fast fling3Raster and decode threads | The checkerboard shows for a frame or two at the leading edge of a scroll | Recording a fling on a low-end device; Layer borders show which tiles exist | Lighter content in long scrollers (smaller images, fewer effects); avoid huge layers with expensive paint | Smooth motion with content arriving late, which is the intended trade |
| Soft or blurry text on a scaled layer2Compositor thread | A will-change: transform element scaled from script keeps its old raster, so text looks soft | Visual check at the end of a scale; compare with the hint removed | Remove the hint when the scale settles so the element is rastered at its new size | Readable but fuzzy until re-rastered |
| Fixed child or z-index breaks after promotion1Renderer main thread | A fixed close button scrolls with its panel; a dropdown in a promoted card hides under the next card | Layout bugs that appear only when will-change or a transform is present | Put fixed overlays outside promoted ancestors, or in the top layer (a dialog or popover) | Wrong placement, not a crash |
| Scroll waits for the main thread2Compositor thread | A touch or wheel listener that is not passive covers the scroller, so every scroll waits for JavaScript | Scrolling performance issues in DevTools; slow scroll regions in the Layers panel | passive listeners, touch-action for gestures, native scroll-snap instead of script-driven swipes | Scroll still works, but can start a whole long task late |