Critical Rendering Path: Paint Stage
The Paint stage records drawing instructions into display lists—it does not produce pixels. Following Prepaint (property tree construction and invalidation), Paint walks the layout tree and generates a sequence of low-level graphics commands stored in Paint Artifacts. These artifacts are later consumed by the Rasterization stage, which executes them to produce actual pixels on the GPU.
Abstract
Paint is a recording phase, not a drawing phase. The key mental model:
Fragment Tree + Property Trees → Paint → Paint Artifact (Display Items + Paint Chunks)Core concepts:
- Display Items: Atomic drawing commands (e.g.,
DrawRect,DrawTextBlob) identified by a client pointer (which DOM element) and type. ThePaintControllercaches previous display items for reuse. - Paint Chunks: Sequential display items grouped by identical PropertyTreeState (transform_id, clip_id, effect_id, scroll_id). Chunks are the unit of compositor layer assignment.
- Paint Order: Governed by the CSS stacking context algorithm (CSS 2.1 Appendix E). Elements within a stacking context paint in a strict back-to-front order; different stacking contexts never interleave.
Why recording, not drawing?
- Resolution independence: Display lists rasterize at any scale without quality loss (HiDPI, pinch-zoom)
- Off-main-thread rasterization: Artifacts serialize to the compositor/GPU process without blocking JavaScript
- Caching: Unchanged display items are reused; only invalidated items are re-recorded
Architecture evolution (CompositeAfterPaint, M94+): Before M94, compositing decisions happened before paint, creating circular dependencies. Now paint produces a clean artifact first, then layerization occurs—reducing 22,000 lines of code and improving scroll latency by 3.5%+ (99th percentile).
Display Items and the Paint Artifact
Paint transforms the layout tree into a Paint Artifact—a data structure containing all information needed for rasterization.
Display Item Types
Display items are the atomic units of paint output. Each item represents a single drawing operation:
| Type | Description | Example Source |
|---|---|---|
DrawingDisplayItem | Contains Skia paint operations | Background colors, borders, text, images |
ForeignLayerDisplayItem | External content | Plugins, iframes, <video> surfaces |
ScrollbarDisplayItem | Scrollbar rendering | Overlay and classic scrollbars |
ScrollHitTestDisplayItem | Hit-test regions for scrolling | Scroll containers |
Identity: Each display item is identified by a client pointer (which LayoutObject generated it) and a type enum (background, foreground, outline, etc.). This identity enables the PaintController to match items across frames for caching.
PaintController and Display Item Caching
The PaintController holds the previous frame’s paint result as a cache:
“If some painter would generate results same as those of the previous painting, we’ll skip the painting and reuse the display items from cache.” — Chromium Paint README
How caching works:
- Before painting, the
PaintControllerreceives the previous artifact - During paint, each display item is compared against its cached predecessor by client and type
- If the item matches exactly (same client, same type, same content), the cached version is reused
- If different, the new item is recorded and the old one is invalidated
Subsequence caching extends this to entire paint subtrees. If a PaintLayer hasn’t changed, its entire subsequence of display items can be reused without re-walking the layout tree.
Paint Chunks
Paint chunks partition display items by their PropertyTreeState—the 4-tuple (transform_id, clip_id, effect_id, scroll_id) from Prepaint.
Why chunks? During layerization (after paint), the compositor needs to know which display items share the same visual effects. Items with different transform nodes can’t be merged into the same compositor layer without visual artifacts. Chunks make this grouping explicit.
Chunk boundaries occur when:
- PropertyTreeState changes (different transform, clip, effect, or scroll ancestor)
- Display item type changes require isolation (e.g., foreign layers)
- Hit-test regions need explicit marking
Real-world example: A page with 1,000 display items might produce 50 paint chunks. Each chunk references a PropertyTreeState and a contiguous range of display items. The layerization stage maps chunks to cc::Layer objects based on compositing requirements.
Paint Order and Stacking Contexts
Paint order is not arbitrary—it follows the CSS stacking context algorithm specified in CSS 2.1 Appendix E. Incorrect paint order produces visual bugs where elements appear behind content they should overlap.
The Stacking Context Algorithm
For each stacking context, descendants paint in this order:
- Stacking context background and borders
- Descendants with negative z-index (most negative first)
- In-flow, non-positioned, block-level descendants
- Non-positioned floats
- In-flow, inline-level descendants (text, images, inline-blocks)
- Positioned descendants with
z-index: autoorz-index: 0 - Positioned descendants with positive z-index (lowest first)
Critical property: Stacking contexts are atomic. No descendant of one stacking context can interleave with descendants of a sibling stacking context. This atomicity is why z-index on a child cannot escape its parent’s stacking context.
Stacking Context Creation
Properties that create new stacking contexts include:
position(relative/absolute/fixed/sticky) withz-index≠ autoopacity< 1transform,filter,perspective,clip-path,maskisolation: isolatemix-blend-mode≠ normalwill-changewith transform/opacity/filter (in some browsers)contain: paintorcontain: layout paint
Design rationale: Each property creates a new compositing scope because it requires the subtree to be rendered as a unit before applying the effect. Opacity, for instance, must multiply against the combined alpha of all descendants—not each descendant individually.
Paint Order Implementation in Blink
Blink implements paint order through PaintLayerPainter and ObjectPainter classes. The PaintLayerPainter::Paint() method walks layers in stacking order, recursively painting:
- Negative z-index children
- The layer itself (backgrounds, non-positioned content, floats, inline content)
- Positive z-index children
Edge case: CSS View Transitions can alter paint order dynamically by creating pseudo-elements that participate in the stacking context. Debugging paint order issues during transitions requires inspecting the view-transition pseudo-element tree.
The CompositeAfterPaint Architecture
As of Chromium M94, paint happens before compositing decisions—a fundamental shift from the legacy architecture.
Why CompositeAfterPaint?
Before M94 (legacy):
Style → Layout → Compositing → PaintCompositing had to guess which elements needed layers before knowing their paint output. This created circular dependencies:
- Compositing needed to know paint order (which elements overlap)
- Paint order depended on compositing decisions (which elements had layers)
The result: fragile code, 22,000+ lines of heuristics, and difficulty reasoning about behavior.
After M94 (CompositeAfterPaint):
Style → Layout → Prepaint → Paint → Layerize → RasterizePaint produces a clean artifact independent of layer decisions. Layerization examines the artifact and assigns paint chunks to cc::Layer objects based on clear criteria.
Measurable Improvements
The CompositeAfterPaint migration yielded:
| Metric | Improvement |
|---|---|
| Code removed | 22,000 lines of C++ |
| Chrome CPU usage | -1.3% overall |
| 99th percentile scroll latency | -3.5%+ |
| 99th percentile input delay | -2.2%+ |
Source: Chromium BlinkNG
Paint Chunk → Compositor Layer Mapping
After paint produces chunks, the layerization stage creates cc::Layer objects:
- Each paint chunk has a PropertyTreeState
- Chunks requiring different composite reasons (e.g.,
will-change: transform) get separate layers - Adjacent chunks with compatible states may merge into a single layer
- Each layer receives a reference to its paint chunks’ display items
This separation allows the compositor to optimize layer count independently of paint logic.
Paint Invalidation
Paint invalidation determines which display items need re-recording. While Prepaint marks display item clients as dirty, the actual invalidation logic integrates with paint recording.
Invalidation Granularity
Paint invalidation operates at two levels:
Chunk-level invalidation:
- Triggered when PropertyTreeState changes
- Triggered when display item order within a chunk changes
- The entire chunk’s display items are re-recorded
Display-item-level invalidation:
- When chunks match (same order, same PropertyTreeState)
- Individual items are compared by client and type
- Only changed items are re-recorded
Raster Invalidation
After paint produces new display items, the RasterInvalidator computes which pixel regions need re-rasterization:
| Invalidation Type | Trigger | Behavior |
|---|---|---|
| Full | Layout change, PropertyTreeState change | Invalidates old AND new visual rects |
| Incremental | Geometry change only | Invalidates only the delta between old and new rects |
Real-world example: A blinking cursor in a text field:
- Only the cursor’s
DrawingDisplayItemis re-recorded (display item invalidation) - Only the cursor’s pixel rect is re-rasterized (~16×20 pixels)
- The surrounding text field’s display items are cached and reused
This granular invalidation is why typing in a text field doesn’t repaint the entire page.
Isolation Boundaries (contain: paint)
contain: paint creates an isolation boundary—a barrier that limits invalidation propagation:
.widget { contain: paint; /* Isolation boundary */}Effects:
- Descendants are clipped to the element’s padding box
- A new stacking context is created
- Paint invalidation inside the boundary doesn’t affect outside
- If the element is off-screen, paint is skipped entirely for the subtree
Performance impact: On a page with 50 isolated widgets, invalidating one widget’s contents triggers paint only for that widget. Without isolation, invalidation might propagate to sibling widgets or ancestors.
Paint Performance Characteristics
Not all CSS properties have equal paint cost. Understanding relative expense helps diagnose performance issues.
Expensive vs. Cheap Paint Operations
Expensive operations:
| Operation | Why Expensive |
|---|---|
box-shadow with large blur | Generates blur shader; no hardware fast-path |
border-radius + box-shadow | Non-rectangular, can’t use nine-patch optimization |
filter: blur() | Per-pixel convolution |
mix-blend-mode | Requires reading backdrop pixels |
| Complex gradients | Multiple color stops, radial patterns |
background-attachment: fixed | Repainted every scroll frame |
Cheap operations:
| Operation | Why Cheap |
|---|---|
| Solid colors | Single fill operation |
| Simple borders | Rectangle primitives |
opacity | Compositor-only (no repaint) |
transform | Compositor-only (no repaint) |
Compositor-Only Properties
Some visual changes skip paint entirely:
transform: Updates PropertyTreeState transform node; no display item changeopacity: Updates PropertyTreeState effect node; no display item change
These changes flow directly to the compositor thread, which applies them during compositing without main-thread involvement.
Failure mode: If you animate left or top instead of transform, each frame triggers layout, prepaint, and paint—not just compositor updates. This causes jank when the main thread is busy.
/* ❌ Triggers full pipeline every frame */.animate-position { animation: move 1s;}@keyframes move { to { left: 100px; }}
/* ✅ Compositor-only, smooth even under main-thread load */.animate-transform { animation: slide 1s;}@keyframes slide { to { transform: translateX(100px); }}DevTools Paint Profiling
Chrome DevTools provides paint debugging:
- Performance panel: Shows “Paint” timing in frame timeline
- Rendering tab → Paint flashing: Green overlays on repainted regions
- Layers panel: Shows compositor layers and their paint reasons
What to look for:
- Large green flashes on scroll (indicates main-thread paint, not compositor-only)
- Frequent paint events for static content (indicates invalidation bugs)
- Paint times >10ms (blocks frame budget)
Edge Cases and Failure Modes
Hit-Test Data Recording
Hit testing is performed in paint order. The paint system records hit-test information alongside visual data:
“Hit testing is done in paint-order, and to preserve this information the paint system is re-used to record hit test information when painting the background.” — Chromium Compositor Hit Testing
Failure mode: Non-rectangular opacity or filters can create “holes” in hit-test opaqueness. Clicks in these holes fall through to elements below, even if visually the element appears solid.
Stacking Context + View Transitions
CSS View Transitions create pseudo-elements (::view-transition-*) that participate in stacking contexts. During a transition:
- Old and new content render into separate snapshots
- Pseudo-elements animate between states
- Paint order of transition pseudo-elements can differ from final DOM order
Gotcha: Elements with explicit z-index during transitions may paint in unexpected order relative to transition pseudo-elements.
will-change Memory Costs
will-change: transform promotes elements to compositor layers, consuming GPU memory:
/* ❌ 1000 list items = 1000 layers = memory exhaustion */.list-item { will-change: transform;}
/* ✅ Promote only during animation */.list-item.animating { will-change: transform;}Symptoms of overuse:
- Checkerboarding (white rectangles) during scroll
- Browser sluggishness
- GPU process crashes on memory-constrained devices
Each layer consumes width × height × 4 bytes of GPU memory. A 1920×1080 layer costs ~8MB; 100 such layers consume 800MB.
background-attachment: fixed Performance
Fixed backgrounds repaint on every scroll frame because their position relative to content changes:
/* ❌ Triggers paint every scroll frame */.hero { background-image: url(large-image.jpg); background-attachment: fixed;}This defeats the compositor’s ability to scroll content without main-thread involvement.
Alternative: Use position: fixed on a separate element with transform: translateZ(0) to achieve similar visual effect with compositor-only scrolling.
Conclusion
Paint is the recording phase that converts layout geometry into drawing commands. Understanding that paint produces display items and paint chunks—not pixels—clarifies its role in the rendering pipeline.
Key takeaways:
- Paint records, rasterization draws: Display lists enable caching, resolution independence, and off-main-thread execution
- Stacking contexts govern paint order: The CSS algorithm is deterministic; understanding it prevents z-index bugs
- CompositeAfterPaint simplified the architecture: Paint is now independent of layer decisions, reducing code and improving performance
- Invalidation is granular: Chunk-level and display-item-level caching minimize re-work
contain: paintcreates isolation: Boundaries limit invalidation scope and enable paint skipping for off-screen content- Compositor-only properties skip paint:
transformandopacityanimations don’t require re-recording
For production optimization, prefer compositor-only animations, use contain: paint on independent UI components, and avoid expensive operations like large blur shadows on frequently-changing elements.
Appendix
Prerequisites
- Prepaint: Property trees and paint invalidation marking
- Layout Stage: Fragment tree construction
- CSS Stacking Context: Understanding z-index and paint order
- Familiarity with DevTools Performance panel
Terminology
| Term | Definition |
|---|---|
| Display Item | Atomic drawing command in a paint artifact (e.g., DrawRect, DrawTextBlob) |
| Paint Artifact | Output of paint stage: display items partitioned into paint chunks |
| Paint Chunk | Sequential display items sharing identical PropertyTreeState |
| PropertyTreeState | 4-tuple (transform_id, clip_id, effect_id, scroll_id) identifying visual effect context |
| PaintController | Component managing display item recording and caching |
| Stacking Context | Isolated 3D rendering context for z-ordering; created by various CSS properties |
| CompositeAfterPaint | Chromium M94+ architecture where paint precedes layerization |
| Isolation Boundary | Barrier (e.g., contain: paint) limiting paint invalidation propagation |
| Raster Invalidation | Determining which pixel regions need re-rasterization based on paint changes |
| Skia | Open-source 2D graphics library executing display item commands |
Summary
- Paint records drawing commands into display items; it does not produce pixels
- Paint Artifacts contain display items partitioned into Paint Chunks by PropertyTreeState
- Stacking context rules (CSS 2.1 Appendix E) govern paint order—contexts are atomic and never interleave
- CompositeAfterPaint (M94+) moved layerization after paint, eliminating circular dependencies and improving scroll latency 3.5%+
- PaintController caching reuses unchanged display items across frames
contain: paintcreates isolation boundaries for scoped invalidation and off-screen paint skipping- Compositor-only properties (
transform,opacity) bypass paint entirely
References
- CSS 2.1: Appendix E - Elaborate description of Stacking Contexts — Specification for paint order algorithm
- CSS Containment Module Level 2 — Specification for
contain: paintbehavior - HTML Specification: Update the Rendering — Where paint fits in the event loop
- Chromium Blink Paint README — Implementation details and caching semantics
- Chromium Platform Paint README — Paint artifact and display item structures
- Chromium RenderingNG Architecture — Pipeline overview and property tree integration
- Chromium BlinkNG — CompositeAfterPaint rationale and performance improvements
- Chromium Slimming Paint — Evolution from legacy paint to property tree-based architecture
- Chromium Compositor Hit Testing — Hit test data recording during paint
- web.dev: Simplify Paint Complexity — Practical optimization guidance
- Chrome DevTools: Analyze rendering performance — Paint profiling tools
Read more
-
Previous
Critical Rendering Path: Prepaint
Browser & Runtime Internals / Critical Rendering Path 14 min readPrepaint is a RenderingNG pipeline stage that performs an in-order traversal of the LayoutObject tree to build Property Trees and compute paint invalidations. It decouples visual effect state (transforms, clips, filters, scroll offsets) from the paint and compositing stages, enabling compositor-driven animations and off-main-thread scrolling.
-
Next
Critical Rendering Path: Commit
Browser & Runtime Internals / Critical Rendering Path 12 min readCommit is the synchronization point where the Main Thread hands over processed frame data to the Compositor Thread. This blocking operation ensures the compositor receives an immutable, consistent snapshot of property trees and display lists—enabling the dual-tree architecture that allows rasterization to proceed independently while the main thread prepares the next frame.