Concepts — Paint, Display Items, and Property Trees

Phase 3 · Spec area §14. Prerequisites: bi-07, bi-08.


0. Orientation

The 60-second version

Paint does not draw pixels. It records display items — a list of drawing commands — which someone else later rasterises. Separating "decide what to draw" from "actually draw it" is what makes the whole modern rendering architecture possible.

The idea that does the most work here is the property tree:

Instead of baking transforms, clips, effects and scroll offsets into each painted item, Blink keeps them in separate trees and references them. So changing a transform updates a node in a tree — it does not re-record a single display item.

That is the mechanism behind "animate transform, not top." It is not a folk optimisation; it is the difference between updating one tree node and re-running paint.

Why this module changes how you read CSS advice

You changeWhat has to happen
transform, opacity on a composited layerupdate a property-tree node — no repaint
color, background, box-shadowre-record display items, re-raster
top, width, marginre-run layout, then paint, then raster

And a stacking context is created by more things than people expect — transform, opacity < 1, filter, will-change, contain: paint — which is why "my z-index stopped working" is nearly always an ancestor that quietly became one, often from a performance change (fe-05).

The part that surprises everyone

Hit testing has its own copy of the geometry. The compositor needs to answer "what is under this finger?" without waiting for the main thread, which is why the input path in bi-02 and bi-10 works at all — and why synthetic events that skip hit testing behave differently from real ones (fw-12).

What you should be able to do after this module

CapabilityEvidence you have it
Explain compositor-only propertiesDerive it from property trees, not from a memorised list
Diagnose a z-index failureEnumerate what creates a stacking context, including performance hints
Read a paint profileDistinguish re-record from re-raster from re-layout
Explain a pixel-snapping artefactSay why an edge looks wrong rather than calling it a rounding bug

1. Why a Principal Engineer needs this

1. "Paint" is where the folk model is most wrong. Most engineers believe paint means "drawing pixels." It does not: Blink's paint stage produces a display list — a recording of drawing commands — and rasterisation into pixels happens later, elsewhere, possibly on another thread or process. Once you know this, "why did my change cause repaint but not relayout, and why was it still cheap?" becomes answerable.

2. Property trees are the data structure that makes compositing tractable, and they are the bridge between this module and bi-10. Understanding them is what lets you predict whether an effect can be handled by the compositor alone.

3. Hit testing lives here. Every "why did my click land on the wrong element" question is a paint-order and hit-test question, and paint order is not DOM order.


2. Mental Model

2.1 Paint produces a recording, not pixels

Layout (fragments + geometry)
   |
   v  PRE-PAINT: build/update property trees, compute paint invalidation
   |
   v  PAINT: walk in paint order, emit display items
   |
   v  Display list, grouped into paint chunks
   |
   v  COMMIT to the compositor            <-- bi-10 takes over here
   |
   v  RASTER (tiles) -> GPU -> pixels

Paint is cheap relative to raster because it is recording, not drawing. A display item is "draw this rect with this paint," not a bitmap.

2.2 Paint order is not DOM order

Paint order is defined by the CSS painting model: stacking contexts, z-index, positioning, and the specified ordering of backgrounds, floats, inlines, and positioned descendants. A stacking context is an atomic unit — its descendants cannot be interleaved with content outside it, which is exactly what makes z-index inside a stacking context local.

Properties that create stacking contexts (opacity < 1, transform, filter, will-change, isolation, and others) therefore change structure, not just appearance. This is why adding opacity: 0.999 "fixes" a z-index bug — and why doing so is a smell rather than a fix.

2.3 Property trees

Rather than baking transforms, clips and effects into each display item, Blink maintains separate trees:

  • Transform tree — the transform hierarchy.
  • Clip tree — clipping regions.
  • Effect tree — opacity, filters, masks, blend modes.
  • Scroll tree — scrollable regions and their offsets.

Each paint chunk references nodes in these trees. The payoff is decisive:

If a change only alters a property-tree node, the display list does not need to be re-recorded, and rasterised tiles do not need to be redrawn. The compositor can apply the new value to already-rastered content.

That is the entire mechanical basis for "animate transform and opacity, not top and width." It is not folklore and it is not about GPUs being fast — it is that those two properties are representable as property-tree changes, so the pipeline can skip paint and raster.

2.4 Paint invalidation

Like style and layout, paint is invalidated rather than recomputed eagerly. Pre-paint walks the tree, updates property trees, and determines what must be repainted. The interesting question — and the lab — is what scope a given change invalidates, and which changes invalidate nothing at all.

2.5 Hit testing

Hit testing walks the painted representation in reverse paint order to find the topmost element at a point. Consequences: pointer-events, overlapping stacking contexts, and transforms all affect hit testing, and the answer can differ from what the DOM tree suggests. Compositor-side hit testing also matters for scroll (bi-10), because the compositor must decide without the main thread whether it can handle an input.


3. Under the Hood

ConcernWhat to look for
Paint entry, painters per box typecore/paint/
Display items and the display listdisplay item + list types in platform/graphics/ and core/paint/
Paint chunkschunk types alongside the display list
Property treesproperty-tree node types; pre-paint tree builder
Paint invalidationthe paint invalidator
Hit testinghit-test request/result types

core/paint/README.md (~30 KB) is the authoritative description; read it rather than inferring the architecture from code.


3.5 Deep dive: PrePaint does two jobs

core/paint/README.md names the phase precisely. PrePaintTreeWalk walks the whole layout tree, from the root FrameView, across frame boundaries, in-order — and the README explains why in-order matters: it lets the walk efficiently compute DOM-order hierarchy such as the parent containing block.

It has exactly two goals:

  1. Paint invalidation — mark what must be painted differently from the cached painting.
  2. Building paint property trees — transform, clip, effect, scroll.

Paint invalidation, mechanically

Before PrePaint, objects are marked as needing invalidation checking by style change, layout change, compositing change, and so on. PrePaint then traverses marked subtrees in pre-order and invalidates the display item clients that would generate different display items.

The machinery: a root PaintInvalidatorContext is created for the LayoutView; each visited object gets one derived from its parent's, tracking the painting layer that will initiate its painting. PaintInvalidator initialises the context and calls LayoutObject::InvalidatePaint(), which dispatches to a type-specific invalidator such as BoxPaintInvalidator.

Notice the shape. Mark-then-walk-then-invalidate is the same two-phase structure as style invalidation (bi-07: features → pending invalidations → push) and dirty-layout tracking (bi-08). Three subsystems, one pattern: record cheaply during mutation, resolve precisely once per frame. If you take a single architectural idea from the rendering pipeline, take that one — it is what makes the whole thing incremental.


3.6 Deep dive: display items, chunks, and the PaintController

Paint walks the PhysicalFragment tree (bi-08's output) in paint order and produces display items via static painter classes such as BoxFragmentPainter, appending to a PaintController.

Two facts from the README that carry a lot of weight:

  • There is only one PaintController for the entire LocalFrameView. Painting is not per-element bookkeeping; it is one list for the frame.
  • The controller segments the display item list into PaintChunks: sequential display items that share a common property tree state.

That second definition is the one to memorise, because it explains why property trees exist at all:

display item, display item, display item   ← same transform/clip/effect  ─┐
display item, display item                 ← different clip              ─┤ chunks
display item                               ← different transform         ─┘

A chunk is precisely "a run of drawing that shares the same answers to where, clipped by what, and with what effect." Change a transform node and you change a chunk's property state, not its contents — so nothing has to be re-recorded and nothing re-rastered. That is the entire transform/opacity fast path, stated in terms of the data structure.

Two layers of paint caching

The README describes both, and they operate at different granularities:

LayerMechanismSkips
Display item cachingif a painter would create a DrawingDisplayItem identical to last time, reuse itone item
Subsequence cachingSubsequenceRecorder in PaintLayerPainter::PaintContents() records all items in a scope; if the layer would produce identical items, reuse the whole runan entire layer

Subsequence caching is the interesting one: it is a memoisation of a subtree's paint output, keyed on "nothing that affects this layer changed." Same idea as layout-result caching (bi-08) and computed in a reactivity system (fw-03) — and the same failure mode if the key is incomplete.

You have now seen result-caching-keyed-on-complete-inputs in three engine subsystems. When fw-03 asks you to reason about computed invalidation, that is not an analogy; it is the same problem at a different scale.


3.7 Deep dive: what creates a stacking context

Paint order is not DOM order, and the list of things that create a stacking context is longer than most engineers expect. A non-exhaustive but practical list:

  • position other than static with a z-index other than auto
  • position: fixed or sticky (always)
  • opacity less than 1
  • transform, scale, rotate, translate, perspective other than none
  • filter, backdrop-filter, mask, clip-path other than none
  • mix-blend-mode other than normal
  • isolation: isolate
  • will-change naming any property that would create one
  • contain: paint, contain: layout, content-visibility other than visible
  • flex/grid items with z-index other than auto
  • view-transition-name other than none

The practical consequence: many properties applied for purely visual reasons change paint structure. opacity: 0.99 "fixing" a z-index bug is the canonical example — it works because it creates a stacking context, which is a structural change disguised as an aesthetic one.

The debugging heuristic: when z-index "doesn't work," the element is almost always being compared against siblings inside a stacking context you did not know existed. Find the nearest ancestor with any property from that list.


3.8 Deep dive: pixel snapping and why edges look wrong

The paint README has a whole section on pixel snapping and bluriness, which tells you it is a recurring source of bugs.

Layout works in LayoutUnit, a fixed-point type with sub-pixel precision (1/64 px). Paint must eventually produce device pixels. The gap between them produces:

  • Blurry text or borders when a box lands on a fractional device pixel — most visible on non-integer devicePixelRatio (1.25, 1.5) which is extremely common on Windows laptops.
  • Off-by-one seams between adjacent boxes when each is snapped independently and they round in different directions.
  • A 1px line that renders as 2px of grey rather than 1px of black.

Why sub-pixel layout at all? Because integer layout accumulates error across many boxes: 100 boxes each rounded up by 0.4px is a 40px drift. Sub-pixel layout keeps the positions accurate and snaps only at paint time.

The practical guidance:

  • Snapping happens at paint, so investigating "why is this blurry" means looking at the paint-time transform, not the CSS.
  • A fractional transform: translate() on an ancestor moves everything below onto fractional positions — this is why an animation can make an entire subtree blurry mid-flight and crisp at rest.
  • will-change: transform promotes to a layer that may be rastered at a fixed scale, which is a separate cause of blurriness during scaling animations.

3.9 Deep dive: hit testing, and the compositor's copy

Hit testing walks the painted representation in reverse paint order to find the topmost element at a point. Everything that affects paint order affects hit testing, including transforms, which is why a visually-moved element is clickable in its new location.

The part that is easy to miss: the compositor needs to hit test too. When input arrives (bi-10), the compositor thread must decide without the main thread whether it can handle the event — for example, whether the point is inside a scroller with no blocking listeners. So paint produces hit test data for the compositor alongside display items.

Two consequences:

  • A non-passive listener on a large region degrades the compositor's ability to answer, which is the mechanism behind bi-10's scroll advice.
  • Elements with pointer-events: none are excluded from hit test regions, which is why it is an effective (if blunt) fix for an invisible overlay eating clicks.

3.10 The property trees, enumerated

Four trees, each answering one question about a paint chunk:

TreeQuestionChanged by
Transformwhere is it?transform, scroll offsets, device scale
Clipwhat is it clipped to?overflow, clip-path, border-radius clipping
Effecthow is it composited?opacity, filter, mask, mix-blend-mode
Scrollwhich scroller moves it?scroll containers

They are separate trees, not one tree of composed state, and that separation is the design. A scroll changes one node in the scroll tree; a fade changes one node in the effect tree. If these were baked into display items, every scroll would re-record the world.

Recall bi-07's data: only 4 properties declare invalidate: ["compositing"], and 5 declare transform-data/transform-other. The set of genuinely cheap-to-animate properties is small, specific, and enumerable — not a vibe.


4. Anti-Patterns

"Paint = pixels." It is a recording.

Animating top/left/width/height. Forces layout → paint → raster every frame.

will-change everywhere. It creates stacking contexts and compositing layers, which cost memory and can reduce performance. It is a hint with a real price.

Assuming z-index is global. It is scoped to the stacking context.

Debugging click-target bugs in the DOM inspector alone. The answer is usually in paint order or a transformed ancestor.


5. Trade-offs

Property trees vs baking properties into display items. Separate trees add indirection and a whole subsystem to maintain, and they are what makes cheap compositor-only updates possible. This is one of the clearest "complexity that pays for itself" cases in the renderer — and the right §46 entry for this module.

Record-then-raster vs draw directly. Recording allows the raster to happen off the main thread, at a different scale, and to be reused. It costs an intermediate representation and the machinery to invalidate it.

More compositing layers vs fewer. Each layer avoids repaint but costs memory and composition time. There is no universally right answer, which is why the browser uses heuristics — and why author hints like will-change can make things worse.


6. Lab — mini-browser M10–M11

  1. Display list. Walk your layout tree in paint order and emit display items ({type, rect, color, …}). Do not draw yet.
  2. Stacking contexts. Implement z-index and at least one property that creates a stacking context. Prove ordering with a test case that changes when you remove the stacking-context rule.
  3. Property trees. Add a transform tree. Represent a translated subtree as a node reference rather than by baking coordinates into items.
  4. Raster. Draw the display list to a canvas.
  5. The payoff experiment. Animate a subtree two ways: (a) by changing layout position and re-recording, (b) by changing only a transform node. Measure both. Report how much of the pipeline each skips.
  6. Hit testing. Implement reverse-paint-order hit testing and find a case where it disagrees with DOM order.

Deliverable: the stage-5 measurement, plus a written statement of exactly which stages were skipped in case (b) and why they could be skipped.


7. Failure Lab

  1. Bake transforms into display items. Re-run the stage-5 experiment. Show that the cheap path is now impossible. This is the strongest possible argument for property trees.
  2. Break paint order. Paint in DOM order instead. Find markup that renders wrong.
  3. Invalidate too little. Skip paint invalidation for a property that needs it; produce a stale-pixels bug.
  4. Layer explosion. Put will-change: transform on 5,000 elements. Measure memory. Explain why the "optimisation" lost.

8. Debugging Exercise

  1. DevTools: enable paint flashing and layer borders. Find one change that repaints and one that does not.
  2. Compare a top-animated element with a transform-animated one in a Perfetto trace. Name the stages present in one and absent in the other.
  3. Find a hit-test bug you can only explain via stacking contexts.
  4. In the checkout, find where a property-tree-only change avoids repaint. Quote the condition.

9. References

  • CSS 2 Appendix E (painting order), CSS Positioned Layout, CSS Transforms, CSS Filter Effects, CSS Compositing and Blending.
  • third_party/blink/renderer/core/paint/README.md — primary.
  • Life of a Pixel.

Core terminology

TermDefinition
PaintRecording drawing commands — not producing pixels
Display itemOne recorded drawing command
Paint chunkA group of display items sharing property-tree state
PaintControllerOwns the display-item list and its caching/invalidation
RasterTurning recorded commands into pixels; happens later, often off the main thread
PrePaintThe pass that builds property trees and computes paint invalidation
Property treeSeparate trees for transform, clip, effect and scroll, referenced by chunks
Transform nodeAn entry in the transform tree; updating it avoids re-recording
Stacking contextAn isolated z-ordering scope
Paint orderThe spec-defined order within a stacking context; z-index only competes inside one
CompositingAssembling rastered layers into a frame on the GPU
LayerisationDeciding which content gets its own composited layer
Pixel snappingAligning to device pixels; the source of "the edge looks wrong" artefacts
Device pixel ratioScaling between CSS pixels and device pixels
Hit testingDetermining which element is at a point; the compositor keeps its own copy
Paint invalidationDeciding which display items must be re-recorded

Mental models

Paint records; raster draws. Keeping these separate is what lets the compositor produce frames from already-painted content while the main thread is busy — the mechanism behind fe-01's most useful field diagnostic: if the composited animation still runs while the page is dead, the main thread is blocked.

Property trees are indirection buying invalidation. By referencing transform/clip/effect/scroll state instead of baking it in, a change to that state becomes a tree-node update rather than a re-record. This is the general technique of separating what changes often from what changes rarely, and it is the honest explanation of "animate transform, not top."

Immutable display items are cacheable for the same reason immutable fragments are. Third instance in the track, and the same failure mode: an incomplete invalidation key produces stale output with no error.

z-index only competes within a stacking context. So a z-index that "stops working" is almost always an ancestor that became one. The cruel version is that contain: paint and will-change — added for performance — are common culprits, which is why fe-05 flags them as smells.

Layers are not free. Each composited layer costs GPU memory, and broad will-change degrades the thing it was meant to improve. Promotion is a targeted tool, applied immediately before an animation and removed after.

Hit testing is a separate geometry problem with a separate copy. The compositor answers "what is under this point?" without the main thread. That is why real input and dispatchEvent differ (fw-12), and why a synthetic click fires through an overlay.

Pixel snapping is a correctness/appearance trade, not a bug. Edges are aligned to device pixels to avoid blurring, and the artefacts that produces — a one-pixel gap, an inconsistent border — are the visible cost of that decision.


Common misconceptions

  1. "Paint draws pixels." Paint records display items. Raster draws, later, elsewhere.

  2. "transform is faster because the GPU does it." It is faster because it is a property-tree node update that avoids re-recording and re-rastering. The GPU is downstream of that.

  3. "will-change is a free hint." It allocates a compositor layer and GPU memory, and it silently creates a stacking context.

  4. "More layers means better performance." Each costs memory and composite work. Over-promotion is a well-known regression.

  5. "z-index: 9999 will put it on top." Only within its stacking context. Find the ancestor that became one — often a performance change.

  6. "Hit testing uses the DOM." The compositor keeps its own geometry so it can answer without the main thread; that is what makes off-main-thread scroll and input possible.

  7. "Pixel-snapping artefacts are rounding bugs." They are the visible cost of aligning to device pixels to avoid blurring.

  8. "Opacity is always cheap." opacity on a composited layer is cheap. opacity that forces a new stacking context and a re-raster of a large subtree is not.


Interview talking points

  • "Paint doesn't draw pixels — it records display items, and raster turns those into pixels later, often off the main thread. That separation is what lets the compositor keep producing frames while the main thread is blocked, which is also the most useful field diagnostic I have: if a composited animation is still moving while the page is dead, you know exactly where the problem is."
  • "Property trees are the idea that does the real work. Transforms, clips, effects and scroll offsets live in separate trees that display items reference rather than bake in, so changing a transform updates a tree node instead of re-recording anything. That's the honest explanation of 'animate transform, not top' — it's not a folk optimisation, it's a different amount of work."
  • "A z-index that stops working is nearly always an ancestor that quietly became a stacking context, and the cruel version is that will-change and contain: paint — both added for performance — are common culprits. So a performance change breaks an overlay."
  • "Hit testing has its own copy of the geometry on the compositor, so it can answer 'what's under this finger' without the main thread. That's also why a synthetic dispatchEvent click fires through an overlay — it skips hit testing entirely, so the test passes and the user can't click the button."
  • "Immutable display items are cacheable for the same reason immutable layout fragments and immutable store state are. Every time, the failure mode is an incomplete invalidation key producing stale output with no error."

10. Principal Engineer Review

  1. Explain to a senior engineer why transform animations are cheap, without saying "GPU."

  2. Property trees add a whole subsystem. Reconstruct the argument that justified them, and name what would be impossible without them.

  3. will-change is a hint with a cost. Write the guidance you would give your organisation — specific enough to act on, without becoming a rule people cargo-cult.

  4. Paint produces a display list rather than pixels. Name three capabilities this enables.

  5. A designer wants a blur-heavy UI. Predict the pipeline consequences, and say what you would measure before agreeing.

  6. Stacking contexts make z-index local. Argue this is good design; then describe the most common way it confuses engineers and how you would teach around it.

  7. Hit testing walks paint order, not DOM order. Give a realistic accessibility or UX bug this causes, and how you would detect it systematically.

  8. The browser decides compositing layers heuristically. Argue for exposing full manual control to authors; then argue against. What do you actually want?

  9. Your app drops frames only while scrolling on low-end Android. Enumerate paint- and raster-side causes, and the order you would investigate them.

  10. You are asked whether a design system should ban opacity transitions on large surfaces. Answer with mechanism, and state what evidence would change your answer.


Connections to other modules

  • bi-08 — layout fragments are paint's input; both immutable, both cacheable, same reason.
  • bi-10 — raster, tiling and the compositor that consumes what paint recorded.
  • bi-07 — which property changes enter the pipeline at which stage.
  • bi-02 — the input path hit testing serves.
  • fe-05 — stacking contexts created accidentally by contain: paint and will-change; the z-index failure mode, from outside.
  • fe-01 — "the spinner still spins while the page is dead" as a first-line diagnostic.
  • fw-10 — transform versus top for positioning a virtualized window.
  • fw-12 — synthetic events skip hit testing, which is the single most valuable fact about testing tools.