Skip to content

    Frontend System Design

    Layout, Paint and Compositing in Depth

    Look inside the rendering pipeline: what style, layout, paint and compositing each produce, what makes work expensive, and how to keep page changes cheap.

    By Published 12 min read

    • frontend-system-design
    • rendering
    • layout
    • paint
    • compositing
    Share

    Standard lessonadvancedFrontend System Design

    Before this: The Critical Rendering Path

    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 transform on positioning, and use CSS containment and content-visibility to 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

    1. Each stage turns one data structure into the next, and a change re-runs only the stages it invalidates.
    2. 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.
    3. 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

    Style, layout, pre-paint and paint run on the main thread; layers, raster and draw followStyle to Layout; Layout to Pre-paint; Pre-paint to Paint; Paint to Layerize (commit); Layerize to Raster; Raster to DrawMAIN THREAD (DOCUMENT LIFECYCLE)COMPOSITOR AND GPU SIDEStyle: computed stylesStylecomputed stylesLayout: fragment treeLayoutfragment treePre-paint: property treesPre-paintproperty treesPaint: display listPaintdisplay listLayerize: composited layersLayerizecomposited layersRaster: GPU texture tilesRasterGPU texture tilesDraw: pixels on screenDrawpixels on screencommit

    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:

    1. the context's own background and borders;
    2. descendant contexts with a negative z-index;
    3. in-flow, non-positioned blocks;
    4. non-positioned floats;
    5. inline content: text and the backgrounds and borders of inline boxes;
    6. positioned descendants with z-index: auto or 0, in tree order;
    7. 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

    flowchart TD accTitle: Painting order inside a stacking context accDescr: From bottom to top: own background and borders, negative z-index contexts, in-flow blocks, floats, inline content, positioned descendants with z-index auto or zero, and positive z-index contexts. A["1. Own background
    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

    sequenceDiagram accTitle: Sliding a drawer with right versus transform accDescr: Animating right needs style, layout and paint on the main thread and a commit every frame. Animating transform on a layer of its own lets the compositor move the existing tiles. participant M as Main thread participant C as Compositor Note over M,C: Animating right M->>M: style, layout, paint (each frame) M->>C: commit new display list C->>C: raster tiles, draw frame Note over M,C: Animating transform on its own layer C->>C: update the transform in the property tree C->>C: draw the existing tiles in a new position

    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: content turns on everything except size; contain: strict turns 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

    flowchart TD accTitle: Choosing how to keep a visual change cheap accDescr: If the effect is movement or fading, animate transform or opacity on a layer. If a big area is mostly off screen, use content-visibility auto. If a widget is isolated, use containment. Otherwise change less. Q1{"What is changing?"} -->|"position, scale,
    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

    NOTE

    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

    Starting architecture: animating right repeats layout and paint on the main thread every frameDrawer animation to Layout (new value); Layout to Paint; Paint to Compositor (commit)MAIN THREADDrawer animation: animates rightDraweranimationanimates rightLayout: every frameLayoutevery framePaint: every framePaintevery frameCompositor: draw frameCompositordraw framenew valuecommit

    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

    1. Reproduce first. Record the slide with CPU throttling and note which stages repeat each frame.
    2. Change the drawer only, behind a flag, and record again: the per-frame style, layout and paint entries for the slide should disappear.
    3. Check the layers in the browser's layer view: the drawer has its own, and nothing else gained one by accident.
    4. 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, width or height. Each frame needs layout.
    • Promoting everything with will-change: layers cost memory.
    • Putting transform on a wrapper and breaking position: fixed inside it.
    • Raising z-index to escape a stacking context. It cannot.
    • Using content-visibility: auto without contain-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-index competes only within one.
    • A layer lets the compositor move content without redrawing it, at a cost in memory.
    • transform skips layout but creates a stacking context and a containing block for fixed and absolute descendants.
    • Containment and content-visibility limit 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.

    In this course:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments