What you'll learn: By the end of this lesson you will be able to name what each rendering stage produces (computed styles, a fragment tree, a display list, layers, a frame), explain why a geometry change costs more than a colour change and a transform change can cost almost nothing, predict painting order inside a stacking context, say what a layer is and what it costs, recognise the side effects of
transformon positioning, and use CSS containment andcontent-visibilityto limit how far a change reaches.
Think of a small animation studio. A layout artist decides where every character stands. Painters fill in each drawing on its own transparent sheet, a cel. A camera operator stacks the cels and photographs the stack. When a character walks across a fixed background, nobody repaints the background: the operator slides one cel. When the director moves a table, the layout artist redoes the scene, because everything around the table must move too.
A browser works the same way. The earlier lessons gave the overview (The Critical Rendering Path) and the thread layout (How a Browser Actually Works). This lesson goes inside layout, paint and compositing, using Chromium's documentation for stage names and the CSS specifications for the rules.
The running example is a product listing page: a sticky header, a grid of 60 cards (illustrative) and a cart drawer that slides in from the right.
The core idea
- Each stage turns one data structure into the next, and a change re-runs only the stages it invalidates.
- Cost follows dependency. Layout can touch many boxes, because one box's size affects its neighbours. Paint redraws pixels but not geometry. Compositing moves finished layers and needs neither.
- The stage names below are Chromium's (RenderingNG), not a standard.
The stages and what each one produces
Chromium's RenderingNG architecture article lists the stages in order. Read them as a chain of data structures.
Figure 1. The stages, split between the main thread and the compositor side
How to read this: the top row is the main thread's work for a frame, left to right. The bottom row continues right to left after the commit. The two warning-tone boxes, layout and paint, are the stages your CSS and JavaScript most directly control.
| Stage | What it does (Chromium's wording, shortened) | Output |
|---|---|---|
| Style | Applies CSS to the DOM | Computed styles |
| Layout | Works out size and position | The immutable fragment tree |
| Pre-paint | Invalidates existing display lists and GPU tiles as appropriate | Property trees, invalidations |
| Paint | Computes how to draw the content | A display list |
| Commit | Copies property trees and display list to the compositor thread | Handoff |
| Layerize | Breaks the display list into layers for independent raster and animation | Composited layer list |
| Raster, draw | Makes GPU texture tiles, then draws a frame | Pixels |
The same article says that visual-effect animations and scrolling can skip layout, pre-paint and paint and run on the compositor thread alone.
Layout: where the cost comes from
Layout answers one question per box: how wide, how tall, and where. In normal flow a block takes its parent's width and stacks below the previous block, so a box's position depends on everything before it and its height can depend on its children. web.dev puts the result plainly: "Layout is normally scoped to the whole document", and changes to geometric properties such as width, height, left or top all require it.
Chromium's engine, LayoutNG, treats layout as a function. Its output is a new, immutable fragment tree, and each fragment stores the parent constraints that produced it. The LayoutNG article says this lets the engine "reuse large portions of the previous tree for incremental layouts", with caches of the measure and layout passes. If a box and the space its parent offers are unchanged, the earlier result can be reused.
Illustration 1: a minimal example. A longer product name makes one card's title wrap to a second line. That card grows, the cards below it in the column move down, and the container's height changes: one text change became many geometry changes. Change the card's color instead and no box moves, so layout has nothing to redo.
MDN calls recalculating position and geometry a reflow, usually followed by repainting. The companion lesson Layout Thrashing and Forced Reflow covers what happens when your own code asks for geometry mid-change.
Paint: drawing instructions in a defined order
Paint turns boxes into ordered drawing instructions (backgrounds, borders, text, shadows), which Chromium stores as a display list. CSS 2.2 defines the painting order inside a stacking context. In summary, for the descendants of an element that generates one:
- the context's own background and borders;
- descendant contexts with a negative
z-index; - in-flow, non-positioned blocks;
- non-positioned floats;
- inline content: text and the backgrounds and borders of inline boxes;
- positioned descendants with
z-index: autoor0, in tree order; - contexts with a positive
z-index, smallest first.
(Appendix E has more sub-steps and draws outlines last.)
Figure 2. Painting order inside one stacking context
and borders"] --> B["2. Negative z-index
contexts"] B --> C["3. In-flow blocks"] C --> D["4. Floats"] D --> E["5. Inline content
(text)"] E --> F["6. Positioned, z-index
auto or 0"] F --> G["7. Positive z-index
contexts"]
How to read this: earlier steps are painted first and end up underneath. A position: relative card is painted in step 6, over the text of non-positioned neighbours, even if it comes earlier in the HTML.
Two consequences follow. z-index competes only among siblings within one stacking context, so a modal with z-index: 9999 inside a context that sits below the header cannot rise above it. And web.dev groups color, background-image and box-shadow as properties that need paint but not layout: they change how boxes look, not where they are.
Chromium's data-structures article adds that the display item list "is stored and reused by later updates", so a small visual change need not redraw everything.
Compositing: layers, tiles and property trees
After commit, the compositor splits the display list into layers so parts of the page can be rasterized and moved independently. A layer is content drawn into its own texture; the compositor can move, scale or fade it without asking the main thread to draw it again.
How does it know what moves? Chromium keeps four property trees: transform, clip, effect (opacity, filters, masks) and scroll. Content is grouped into paint chunks that each share a state in these trees. Layerize generally merges chunks into few layers but keeps apart those expected to change on the compositor thread, such as a scrolled or transform-animated chunk. The payoff, in the article's words: "the second composited layer is moved, but no rasterization is needed."
Figure 3. Sliding a drawer with right versus transform
How to read this: the first block repeats the whole top row of Figure 1 on every frame. The second starts at the compositor, so a long main-thread task need not stop it. Confirm any specific animation in your browser's performance tools.
web.dev's compositor article names the two properties that qualify: "Today there are only two properties for which that is true - transforms and opacity." They pay off when the element has its own layer, which will-change can request.
Illustration 2: a realistic example. On the listing page, scrolling is the common case. The compositor can scroll the grid without redrawing it; a hover effect that changes one card's box-shadow needs paint for that card only. A hover that changed a card's width would need layout for its row, and possibly the whole grid.
Layers are not free
The same article warns that "every layer you create requires memory and management, and that's not free", and that layer textures must be uploaded to the GPU, which is limited by bandwidth and memory. On a low-memory device the cost of many layers can outweigh the benefit. Promote what you animate: a grid of 60 cards (illustrative) that all carry will-change: transform pays for 60 textures to animate none of them.
Side effects of transform
The CSS Transforms specification says any value other than none creates a stacking context and establishes a containing block for absolutely positioned and fixed-position descendants. It also says transforms do not affect the flow of surrounding content, only overflow.
Illustration 3: an edge case. A position: fixed bar inside a transformed wrapper stops being fixed to the viewport and sticks to the wrapper. The 2 a.m. failure below is this bug.
Cutting scope: containment and content-visibility
If cost depends on how far a change reaches, you can build walls. The CSS Containment specification (a W3C Working Draft at the time of writing, Level 2 dated 17 September 2022) defines them:
contain: layout: the box is "totally opaque for layout purposes; nothing outside can affect its internal layout, and vice versa."contain: paint: descendants are clipped to the box's overflow clip edge, and the box becomes a containing block for absolute and fixed descendants.contain: size: the box can be laid out "without needing to examine its descendants", so its intrinsic size is computed as if it were empty (give it an explicit size).contain: contentturns on everything except size;contain: strictturns on all of them.
content-visibility: auto builds on this. MDN says it turns on layout, style and paint containment and, when the element is not relevant to the user, skips its contents while keeping them available to find-in-page and tab order. Skipped content has no size of its own, so pair it with contain-intrinsic-size; MDN's examples use it to keep layout stable. MDN lists the property as Baseline 2024 (newly available), so check support for your audience.
.product-card {
content-visibility: auto;
contain-intrinsic-size: auto 320px; /* illustrative placeholder height */
}
Figure 4. Choosing a way to keep a change cheap
fade"| T["Animate transform or opacity
on its own layer"] Q1 -->|"a big area, mostly
off screen"| V["content-visibility: auto
with contain-intrinsic-size"] Q1 -->|"an isolated widget
that resizes itself"| C["contain: layout (or content)"] Q1 -->|"something else"| R["Change less: fewer boxes,
simpler styles"]
How to read this: start from the effect you want. The first branch avoids layout and paint, the next two limit their reach.
| Technique | What it saves | What it costs | Use it when |
|---|---|---|---|
Animate transform / opacity |
Layout and paint per frame | A layer's memory; transform side effects |
Moving or fading something |
will-change |
A layer ready before the animation | Memory while it is set | A few elements about to animate |
contain: layout / content |
Reach of layout and paint | Clipping, new containing block | Self-contained widgets |
content-visibility: auto |
Work for off-screen sections | A placeholder size; support varies | Long repeated sections |
| Fewer, simpler boxes | Everything | Design effort | Always ask first |
In practice: a cart drawer that janks on mid-range phones
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
On the listing page, tapping "Add to cart" slides the drawer in. On mid-range phones the slide stutters, and field data for the page shows poor responsiveness right after the tap. A profile shows the main thread repeating style, layout and paint on every frame: the drawer animates its right offset from -360px to 0.
Starting architecture
Figure 5. The drawer's per-frame path
How to read this: the compositor only shows what the main thread has already produced, so a slow layout or paint delays the frame itself.
Options considered
| Option | What it fixes | What it costs | Risk |
|---|---|---|---|
Animate transform: translateX() on the drawer |
Per-frame layout and paint | A layer for the drawer | transform on the wrong element |
contain: layout paint on the drawer |
Limits internal reach | Clips overflow | Per-frame work remains |
content-visibility: auto on the cards |
Cheaper page-level layout | Placeholder heights | Layout shift if sizes are wrong |
The decision
| Field | Decision record |
|---|---|
| Context | Animating right forces layout and paint every frame on mid-range phones. |
| Decision | Place the drawer once at its final position, slide it with transform: translateX() and will-change: transform only while opening, and add content-visibility: auto with a sized placeholder to the cards. |
| Consequences, good | The slide needs no per-frame layout or paint; page layout work shrinks. |
| Consequences, bad | One more layer; will-change must be removed afterwards; placeholder heights need upkeep. |
| Revisit when | Field responsiveness stays poor, or memory problems appear on low-end devices. |
Rollout and verification
- Reproduce first. Record the slide with CPU throttling and note which stages repeat each frame.
- Change the drawer only, behind a flag, and record again: the per-frame style, layout and paint entries for the slide should disappear.
- Check the layers in the browser's layer view: the drawer has its own, and nothing else gained one by accident.
- Roll out the card change separately and compare field data for flagged and unflagged traffic.
The 2 a.m. failure
A week later, support reports that on long listing pages the sticky "Checkout" bar scrolls away and the drawer's overlay no longer covers the screen. A follow-up change put transform: translateZ(0) on the page wrapper so everything inside would be promoted. A transform other than none makes its element the containing block for fixed-position descendants, so the "fixed" bar and overlay now position against the wrapper, which is as tall as the whole page.
First three checks: (1) walk up from the broken element in the inspector and look for an ancestor with a transform or contain: paint; (2) compare with the flag off; (3) look for a layer the size of the page in the layer view. Mitigation: remove the wrapper's transform. Follow-up: promote only the animated element, and add a visual test for the fixed bar on a long page.
Architect's notes
- Ask "which stage does this invalidate?" in every review of motion or layout code.
- Promote the animated element, not its ancestors. A layer costs memory and, through
transform, changes positioning. - Treat engine detail as a hypothesis; verify per browser.
Common mistakes
- Animating
left,top,widthorheight. Each frame needs layout. - Promoting everything with
will-change: layers cost memory. - Putting
transformon a wrapper and breakingposition: fixedinside it. - Raising
z-indexto escape a stacking context. It cannot. - Using
content-visibility: autowithoutcontain-intrinsic-size. Skipped sections have no size of their own, so layout can shift as they come into view.
Key takeaways
- The pipeline is a chain of data structures: computed styles, the immutable fragment tree, property trees and a display list, layers and tiles, and a frame.
- A change re-runs only the stages it invalidates: geometry reaches layout, appearance reaches paint, and compositor-only effects can skip both.
- Layout is costly because boxes depend on each other.
- Painting follows a defined order inside each stacking context, and
z-indexcompetes only within one. - A layer lets the compositor move content without redrawing it, at a cost in memory.
transformskips layout but creates a stacking context and a containing block for fixed and absolute descendants.- Containment and
content-visibilitylimit how far a change reaches, with clipping, sizing and support caveats.
Practice exercises
Exercise 1: Starter (30 min). For background-color, margin-top and transform: scale(1.05) on a card, say which stages re-run, then check in the performance tool.
Exercise 2: Intermediate (2 hours). Build the drawer twice, animating right and transform, and record both with CPU throttling. Then wrap its parent in a transform and find out what happens to a position: fixed child.
Exercise 3: Advanced (half day). Add content-visibility: auto to a long repeated list with and without contain-intrinsic-size, record layout cost and shift, and write the decision record.
Related reading
In this course:
- The Critical Rendering Path: how bytes become a first render.
- How a Browser Actually Works: processes, threads and compositor-only animation.
- The Event Loop and the Main Thread: when the rendering update runs relative to your scripts.
- Cumulative Layout Shift and Layout Stability: when layout changes become visible movement.
Official documentation
- Chrome for Developers: RenderingNG architecture, the stages and their outputs.
- Chrome for Developers: RenderingNG key data structures and LayoutNG.
- web.dev: Avoid large, complex layouts and layout thrashing and Stick to compositor-only properties and manage layer count.
- CSS 2.2, Appendix E: stacking contexts and CSS Transforms Module Level 1.
- CSS Containment Module Level 2 and MDN: content-visibility.