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 change | What has to happen |
|---|---|
transform, opacity on a composited layer | update a property-tree node — no repaint |
color, background, box-shadow | re-record display items, re-raster |
top, width, margin | re-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
| Capability | Evidence you have it |
|---|---|
| Explain compositor-only properties | Derive it from property trees, not from a memorised list |
Diagnose a z-index failure | Enumerate what creates a stacking context, including performance hints |
| Read a paint profile | Distinguish re-record from re-raster from re-layout |
| Explain a pixel-snapping artefact | Say 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
| Concern | What to look for |
|---|---|
| Paint entry, painters per box type | core/paint/ |
| Display items and the display list | display item + list types in platform/graphics/ and core/paint/ |
| Paint chunks | chunk types alongside the display list |
| Property trees | property-tree node types; pre-paint tree builder |
| Paint invalidation | the paint invalidator |
| Hit testing | hit-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:
- Paint invalidation — mark what must be painted differently from the cached painting.
- 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
PaintControllerfor the entireLocalFrameView. 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:
| Layer | Mechanism | Skips |
|---|---|---|
| Display item caching | if a painter would create a DrawingDisplayItem identical to last time, reuse it | one item |
| Subsequence caching | SubsequenceRecorder in PaintLayerPainter::PaintContents() records all items in a scope; if the layer would produce identical items, reuse the whole run | an 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:
positionother thanstaticwith az-indexother thanautoposition: fixedorsticky(always)opacityless than 1transform,scale,rotate,translate,perspectiveother thannonefilter,backdrop-filter,mask,clip-pathother thannonemix-blend-modeother thannormalisolation: isolatewill-changenaming any property that would create onecontain: paint,contain: layout,content-visibilityother thanvisible- flex/grid items with
z-indexother thanauto view-transition-nameother thannone
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: transformpromotes 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: noneare 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:
| Tree | Question | Changed by |
|---|---|---|
| Transform | where is it? | transform, scroll offsets, device scale |
| Clip | what is it clipped to? | overflow, clip-path, border-radius clipping |
| Effect | how is it composited? | opacity, filter, mask, mix-blend-mode |
| Scroll | which 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
- Display list. Walk your layout tree in paint order and emit display items
(
{type, rect, color, …}). Do not draw yet. - Stacking contexts. Implement
z-indexand at least one property that creates a stacking context. Prove ordering with a test case that changes when you remove the stacking-context rule. - Property trees. Add a transform tree. Represent a translated subtree as a node reference rather than by baking coordinates into items.
- Raster. Draw the display list to a canvas.
- 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.
- 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
- 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.
- Break paint order. Paint in DOM order instead. Find markup that renders wrong.
- Invalidate too little. Skip paint invalidation for a property that needs it; produce a stale-pixels bug.
- Layer explosion. Put
will-change: transformon 5,000 elements. Measure memory. Explain why the "optimisation" lost.
8. Debugging Exercise
- DevTools: enable paint flashing and layer borders. Find one change that repaints and one that does not.
- Compare a
top-animated element with atransform-animated one in a Perfetto trace. Name the stages present in one and absent in the other. - Find a hit-test bug you can only explain via stacking contexts.
- 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
| Term | Definition |
|---|---|
| Paint | Recording drawing commands — not producing pixels |
| Display item | One recorded drawing command |
| Paint chunk | A group of display items sharing property-tree state |
PaintController | Owns the display-item list and its caching/invalidation |
| Raster | Turning recorded commands into pixels; happens later, often off the main thread |
| PrePaint | The pass that builds property trees and computes paint invalidation |
| Property tree | Separate trees for transform, clip, effect and scroll, referenced by chunks |
| Transform node | An entry in the transform tree; updating it avoids re-recording |
| Stacking context | An isolated z-ordering scope |
| Paint order | The spec-defined order within a stacking context; z-index only competes inside one |
| Compositing | Assembling rastered layers into a frame on the GPU |
| Layerisation | Deciding which content gets its own composited layer |
| Pixel snapping | Aligning to device pixels; the source of "the edge looks wrong" artefacts |
| Device pixel ratio | Scaling between CSS pixels and device pixels |
| Hit testing | Determining which element is at a point; the compositor keeps its own copy |
| Paint invalidation | Deciding 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
-
"Paint draws pixels." Paint records display items. Raster draws, later, elsewhere.
-
"
transformis 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. -
"
will-changeis a free hint." It allocates a compositor layer and GPU memory, and it silently creates a stacking context. -
"More layers means better performance." Each costs memory and composite work. Over-promotion is a well-known regression.
-
"
z-index: 9999will put it on top." Only within its stacking context. Find the ancestor that became one — often a performance change. -
"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.
-
"Pixel-snapping artefacts are rounding bugs." They are the visible cost of aligning to device pixels to avoid blurring.
-
"Opacity is always cheap."
opacityon a composited layer is cheap.opacitythat 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, nottop' — it's not a folk optimisation, it's a different amount of work." - "A
z-indexthat stops working is nearly always an ancestor that quietly became a stacking context, and the cruel version is thatwill-changeandcontain: 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
dispatchEventclick 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
-
Explain to a senior engineer why
transformanimations are cheap, without saying "GPU." -
Property trees add a whole subsystem. Reconstruct the argument that justified them, and name what would be impossible without them.
-
will-changeis 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. -
Paint produces a display list rather than pixels. Name three capabilities this enables.
-
A designer wants a blur-heavy UI. Predict the pipeline consequences, and say what you would measure before agreeing.
-
Stacking contexts make
z-indexlocal. Argue this is good design; then describe the most common way it confuses engineers and how you would teach around it. -
Hit testing walks paint order, not DOM order. Give a realistic accessibility or UX bug this causes, and how you would detect it systematically.
-
The browser decides compositing layers heuristically. Argue for exposing full manual control to authors; then argue against. What do you actually want?
-
Your app drops frames only while scrolling on low-end Android. Enumerate paint- and raster-side causes, and the order you would investigate them.
-
You are asked whether a design system should ban
opacitytransitions 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 bycontain: paintandwill-change; thez-indexfailure mode, from outside.fe-01— "the spinner still spins while the page is dead" as a first-line diagnostic.fw-10—transformversustopfor positioning a virtualized window.fw-12— synthetic events skip hit testing, which is the single most valuable fact about testing tools.