Critical Rendering Path
13 min read

Critical Rendering Path: Compositing

How the compositor thread assembles rasterized layers into compositor frames and coordinates with the Viz process to display the final pixels on screen.

Viz Process

Compositor Thread (cc)

Main Thread

Paint Stage

Record Display Lists

Commit

Sync Property Trees

Tiling & Scheduling

Activate

Pending → Active Tree

Build Compositor Frame

Draw Quads + Render Passes

Surface Aggregator

Combine All Frames

Display Compositor

GPU Draw Commands

Physical Display

The compositing pipeline: the main thread commits property trees to the compositor thread, which builds compositor frames sent to Viz for final display.

Compositing solves a fundamental constraint: the main thread cannot simultaneously execute JavaScript and produce visual updates. Chromium’s solution is a multi-threaded architecture where the compositor thread (cc) operates independently, enabling smooth scrolling and animations even when JavaScript blocks the main thread.

Core mental model:

Main Thread Tree (authoritative) → Commit → Pending Tree → Activate → Active Tree → Compositor Frame → Viz
↑ ↑ rasterization ↑ drawing
│ │ must complete │ always responsive
└── Writes only ──────────────────────── └── Never blocks ────────┘

The key architectural decisions:

DecisionWhy It ExistsWhat It Enables
Two-tree architectureMain thread edits shouldn’t affect in-progress renderingMain thread can prepare frame N+1 while compositor draws frame N
Property trees (4 types)Flattened structure for O(1) lookupsFast transform, clip, effect, scroll calculations without tree traversal
Compositor-driven animationstransform/opacity don’t require layout60fps animations independent of main thread load
Async input routingScroll events handled before reaching main threadResponsive scrolling during JavaScript execution

The compositor thread never makes blocking calls to the main thread—this one-directional dependency is what guarantees responsiveness.


The cc component (Chrome Compositor) is Chromium’s multi-threaded compositing system. It runs in both the renderer process (where Blink is the client) and the browser process (for UI compositing).

┌─────────────────────────────────────────────────────────────────┐
│ Renderer Process │
├─────────────────────────────────────────────────────────────────┤
│ Main Thread │ Compositor Thread (cc) │
│ ──────────────────── │ ────────────────────── │
│ • LayerTreeHost │ • LayerTreeHostImpl │
│ • Layer (public API) │ • LayerImpl (internal) │
│ • Property trees (source) │ • Property trees (copy) │
│ │ • TileManager │
│ │ │ • Scheduler │
│ │ Commit (blocking) │ │ │
│ └──────────────────────▶│ │ │
│ │ ▼ │
│ │ CompositorFrame │
│ │ │ │
└─────────────────────────────────┼─────────┼──────────────────────┘
│ │ IPC
│ ▼
┌─────────┴─────────────────────────────────┐
│ Viz Process │
│ • SurfaceManager │
│ • SurfaceAggregator │
│ • Display Compositor │
└───────────────────────────────────────────┘

The public API (LayerTreeHost, Layer) lives on the main thread. Embedders create layers, set properties, and request commits. The internal implementation (LayerTreeHostImpl, LayerImpl) lives on the compositor thread and handles the actual rendering work.

Chromium maintains three layer trees to decouple content updates from display:

TreeLocationPurpose
Main Thread TreeMain threadAuthoritative source; updated by JavaScript, style, layout
Pending TreeCompositor threadStaging area for new commits; tiles rasterize here
Active TreeCompositor threadCurrently being displayed; source for compositor frames

A fourth tree, the Recycle Tree, caches the previous pending tree to optimize memory allocation.

Why three trees? Without this separation:

  • Commits would block until rasterization completes (frames would drop)
  • Partially-rasterized content would be visible (checkerboard artifacts)
  • Main thread couldn’t prepare the next frame while the current one renders

The Commit synchronizes the main thread tree to the pending tree. This is a blocking operation: the main thread pauses while the compositor copies:

  1. Property trees (transform, clip, effect, scroll)
  2. Layer metadata (bounds, property tree node IDs)
  3. Display lists (paint records for rasterization)

Once commit completes, the main thread resumes and can immediately begin preparing the next frame.

When tiles in the pending tree are sufficiently rasterized, activation occurs: the pending tree becomes the new active tree. The TileManager controls this timing—activation only proceeds when “NOW” priority tiles (visible in viewport) are ready.

Design rationale: Activation is the safety valve preventing checkerboard. If rasterization falls behind, activation delays rather than showing incomplete content.


Property trees are the modern replacement for hierarchical layer transforms. Instead of walking the entire layer tree to compute a node’s final transform, each layer stores node IDs pointing into separate trees.

TreeContentsExample Properties
Transform2D/3D transforms, scroll offsetstransform, translate3d(), scroll position
ClipOverflow clips, clip-pathoverflow: hidden, clip-path
EffectVisual effectsopacity, filter, mix-blend-mode, mask
ScrollScroll metadataScroll chain relationships, scroll bounds

Consider computing the screen-space transform for a deeply nested element:

Legacy approach (layer tree traversal):

transform = identity
for each ancestor from root to element:
transform = transform × ancestor.localTransform
// O(depth) per element, O(depth × layers) total

Property trees approach:

transformNode = propertyTrees.transform[element.transformNodeId]
transform = transformNode.cachedScreenSpaceTransform
// O(1) lookup

Property trees cache computed values at each node. When a transform changes, only affected nodes recompute—not the entire tree. This makes updates O(interesting nodes) instead of O(total layers).

A complex page with 1000+ layers (data visualization, infinite scroll) would see catastrophic commit times with layer tree traversal. Property trees keep commit times bounded regardless of layer count—critical for maintaining 60fps.


The compositor thread produces CompositorFrames, the data structure that travels to Viz for display. A frame is not a bitmap—it’s a structured description of what to draw.

CompositorFrame
├── metadata
│ ├── device_scale_factor
│ ├── frame_token (for timing)
│ └── begin_frame_ack
├── RenderPass[] (from back to front)
│ └── RenderPass
│ ├── id
│ ├── output_rect
│ ├── damage_rect
│ └── DrawQuad[] (drawing primitives)
└── resource_list (texture references)

Draw quads are the atomic drawing primitives:

Quad TypePurposeExample
TextureDrawQuadRasterized tileLayer content
SolidColorDrawQuadColor fillBackgrounds, fallbacks
SurfaceDrawQuadReference to another surfaceCross-origin iframe
TileDrawQuadSingle tile of tiled layerLarge scrolling content
VideoDrawQuadVideo frame<video> element
RenderPassDrawQuadOutput of another render passCSS filter result

Each quad carries geometry (transform, destination rect, clip), material properties (texture ID, blend mode), and layer information for z-ordering.

When content requires intermediate textures, multiple render passes are used:

EffectWhy Intermediate Pass Required
filter: blur()Must render content to texture, then apply blur kernel
opacity on groupChildren blend with each other first, then group blends with background
mix-blend-modeRequires reading pixels from underlying content

Each additional render pass adds GPU overhead (texture allocation, state changes, draw calls). This is why filter and mix-blend-mode are more expensive than transform and opacity.


The compositor thread (LayerTreeHostImpl) handles four critical functions that must remain responsive regardless of main thread state:

Scroll and touch events are intercepted before reaching the main thread. The compositor implements InputHandler, allowing it to:

  • Apply scroll offsets immediately to the active tree’s property trees
  • Handle pinch-zoom by modifying viewport scale
  • Route events to the main thread only when necessary (event listeners, non-composited scrollers)

Design rationale: If scroll events went to the main thread first, a blocked main thread would freeze scrolling. By routing input through the compositor, scrolling remains responsive during heavy JavaScript execution.

Animations targeting transform, opacity, or filter can run entirely on the compositor thread:

/* Compositor-driven: runs on compositor thread */
.smooth {
transition:
transform 0.3s,
opacity 0.3s;
}
/* Main-thread required: triggers layout */
.janky {
transition:
left 0.3s,
width 0.3s;
}

The animation system works by:

  1. Main thread creates animation, marks element for promotion
  2. Animation metadata copies to compositor during commit
  3. Compositor thread ticks animation each frame, updating property tree values
  4. GPU re-composites existing textures with new transform/opacity

No re-layout, no re-paint, no re-raster—the textures already exist.

The TileManager orchestrates rasterization across worker threads:

Priority BinCriteriaTreatment
NOWVisible in viewportMust complete before activation
SOONWithin ~1 viewport distancePrevents checkerboard on scroll
EVENTUALLYFurther from viewportRasterized when idle
NEVEROff-screen, no scroll pathSkipped entirely

Scroll velocity affects binning—fast scrolls expand the “SOON” radius to pre-rasterize more content ahead of the scroll direction.

The Scheduler coordinates the rendering pipeline, managing:

  • BeginFrame signals from Viz (VSync-aligned)
  • BeginMainFrame requests to the main thread
  • Activation timing based on tile readiness
  • Frame submission deadlines

The compositor thread handles these operations independently, meaning they continue during heavy JavaScript execution:

2 collapsed lines
// Example: 3-second main thread block
button.addEventListener("click", () => {
const start = Date.now()
while (Date.now() - start < 3000) {} // Busy loop
console.log("Done!")
})

Compositor-handled (still works):

BehaviorWhy It WorksCaveat
Page scrollingCompositor handles scroll positionScroll event listeners won’t fire until unblocked
Pinch-to-zoomCompositor modifies viewport transform
transform animationsCompositor updates transform node valuesOnly for promoted layers
opacity animationsCompositor updates effect node valuesOnly for promoted layers
Video playbackDecoded in separate processSeeking may need main thread
OffscreenCanvasRendering on worker threadRequires explicit setup

Main-thread-dependent (blocked):

BehaviorWhy It’s Blocked
Click/touch handlersEvent dispatch runs on main thread
Hover state changesRequires style recalculation
Layout-triggering animationsleft, top, width, margin need layout
Text selectionMain thread hit testing
Form inputMain thread event handling
requestAnimationFrameCallbacks run on main thread
/* ✅ Compositor-only: runs during JS execution */
.smooth-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ❌ Main-thread required: freezes during JS execution */
.janky-spinner {
animation: slide 1s linear infinite;
}
@keyframes slide {
to {
margin-left: 100px;
} /* Layout property */
}

The transform animation updates a property tree node value—the compositor applies it to cached GPU textures. The margin-left animation requires layout recalculation, which only the main thread can perform.


The compositor thread doesn’t draw pixels directly—it produces CompositorFrame objects that travel to the Viz process (GPU process) for display.

Each compositor has a CompositorFrameSink connection to Viz:

Renderer Process Viz Process
──────────────── ───────────
CompositorThread SurfaceManager
│ │
│ SubmitCompositorFrame(frame) │
├─────────────────────────────────────────▶│
│ │
│ DidReceiveCompositorFrameAck() │
│◀─────────────────────────────────────────┤
│ │
│ BeginFrame(args) │
│◀─────────────────────────────────────────┤

Frame submission flow:

  1. Compositor builds CompositorFrame (draw quads, render passes, resource references)
  2. Frame submitted via CompositorFrameSink::SubmitCompositorFrame()
  3. Viz acknowledges receipt, allowing compositor to recycle resources
  4. Viz sends BeginFrame signals (VSync-aligned) to pace frame production

A web page often involves multiple renderer processes (cross-origin iframes), plus the browser UI. Viz aggregates all their frames:

Browser UI Frame ────┐
Main Page Frame ─────┼──▶ Surface Aggregator ──▶ Aggregated Frame ──▶ Display
iframe A Frame ──────┤
iframe B Frame ──────┘

The SurfaceAggregator walks SurfaceDrawQuad references recursively, merging frames from all sources. If an iframe’s frame hasn’t arrived, Viz uses the previous frame or a solid color—preventing one slow process from stalling the entire page.

Security: Renderer processes are sandboxed and cannot access GPU APIs directly. All GPU commands go through Viz.

Stability: GPU driver crashes terminate only the Viz process; renderers survive and reconnect. Users see a brief flash rather than losing their tabs.

Resource management: Viz controls GPU memory allocation across all tabs, preventing any single page from consuming all VRAM (Video Random Access Memory).


Layer promotion enables compositor-driven animations but costs memory:

/* Explicit promotion hint */
.will-animate {
will-change: transform;
}
/* Implicit promotion (has side effects) */
.promoted {
transform: translateZ(0); /* Forces own layer */
}

Memory cost: Each layer consumes width × height × 4 bytes (RGBA) of GPU memory. A 1920×1080 layer = ~8MB. Mobile devices with shared GPU memory exhaust resources quickly.

When to promote:

  • Elements with transform/opacity animations
  • Fixed/sticky positioned elements (scroll independently)
  • Content with hardware video or WebGL

When NOT to promote:

  • Static content (wastes memory)
  • Thousands of small elements (layer explosion)
  • Content that changes frequently (re-rasterization costs)

For 60fps animations, stick to properties the compositor can handle without main thread involvement:

PropertyCompositor-Only?Notes
transform YesAll transform functions
opacity Yes
filter⚠️ PartialCompositor can apply, but may need render pass
left, top NoTriggers layout
width, height NoTriggers layout
background-color NoTriggers paint/raster

Reading layout properties after writes forces the browser to synchronously recalculate:

// ❌ Forces layout thrashing
elements.forEach((el) => {
el.style.width = `${el.offsetWidth + 10}px` // Read triggers sync layout
})
// ✅ Batch reads, then writes
const widths = elements.map((el) => el.offsetWidth) // All reads
elements.forEach((el, i) => {
el.style.width = `${widths[i] + 10}px` // All writes
})

Layout thrashing on the main thread delays commits, which delays frame production.


When scrolling reveals tiles that haven’t been rasterized, users see placeholder rectangles (historically a checkerboard pattern, now typically solid colors).

Causes:

  • Scroll velocity exceeds rasterization throughput
  • Memory pressure evicts pre-rasterized tiles
  • Complex content (heavy SVG, many layers) slows rasterization

Debugging: Chrome DevTools → Performance → check for “Rasterize Paint” entries extending beyond frame budgets. The “Layers” panel shows which elements are promoted and their memory consumption.

When too many elements are promoted, memory exhausts and performance degrades.

Symptoms:

  • High GPU memory usage in Task Manager
  • Compositor falls back to software raster
  • Animations stutter despite being transform/opacity only

Debugging: DevTools → Layers panel → sort by memory. Look for unexpected promotions from overlap (elements stacking above promoted content force-promote).

Long commit times steal from the frame budget.

Causes:

  • Thousands of layers (each must sync metadata)
  • Complex property trees (deep nesting)
  • Large display lists (many paint operations)

Debugging: DevTools → Performance → look for long “Commit” entries. The “Summary” tab shows time breakdown.


Compositing is Chromium’s architectural answer to a fundamental constraint: the main thread cannot execute JavaScript and produce visual updates simultaneously. By delegating frame assembly to a dedicated compositor thread with its own layer trees and property trees, the browser maintains responsive scrolling and animations regardless of main thread load.

The key insights for optimization:

  1. Promote judiciously: Layer promotion enables compositor-driven animations but costs GPU memory
  2. Animate compositor-only properties: transform and opacity skip the entire main-thread pipeline
  3. Understand the boundaries: Knowing what the compositor can and cannot do prevents performance surprises
  4. Respect the frame budget: Commit, rasterization, and Viz aggregation all compete for the 16ms window

The three-tree architecture (main → pending → active), property tree design, and one-directional dependency (main → compositor, never reverse) are the foundations enabling 60fps+ rendering on complex, multi-process web applications.


TermDefinition
cc (Chrome Compositor)The multi-threaded compositing system in Chromium
LayerTreeHostMain thread public API for layer management
LayerTreeHostImplCompositor thread implementation; handles input, animations, frame production
Property TreesFour separate trees (transform, clip, effect, scroll) for O(1) property lookups
CompositorFrameData structure containing draw quads and render passes, sent to Viz
Draw QuadAtomic drawing primitive (texture, solid color, surface reference)
Render PassSet of quads drawn to a target (screen or intermediate texture)
Viz (Visuals)Chromium’s GPU process; aggregates frames and produces final display
SurfaceCompositable unit in Viz that receives compositor frames
ActivationTransition from pending tree to active tree after rasterization completes
CheckerboardingVisual artifact when tiles aren’t rasterized before becoming visible
  • cc (Chrome Compositor) runs on a dedicated thread, enabling responsive scrolling and animations during main thread blocks
  • Three-tree architecture (main → pending → active) decouples content updates from display
  • Property trees (transform, clip, effect, scroll) provide O(1) lookups instead of O(depth) traversals
  • Compositor frames contain draw quads and render passes—abstract descriptions, not bitmaps
  • Viz process aggregates frames from all renderer processes and browser UI into a single display output
  • Compositor-only properties (transform, opacity) animate without main thread involvement
  • One-directional dependency: main thread can block on compositor, but compositor never blocks on main thread
  • Chromium Design Docs: How cc Workschromium.googlesource.com

    “The scheduler triggers BeginMainFrame, Blink applies updates, then ProxyImpl copies data to compositor thread structures while temporarily blocking the main thread.”

  • Chromium Design Docs: Compositor Thread Architecturechromium.org

    “This architecture allows us to snapshot a version of the page and allow the user to scroll and see animations directly even when the main thread is blocked.”

  • Chrome Developers: RenderingNG Architecturedeveloper.chrome.com
  • Chrome Developers: RenderingNG Data Structuresdeveloper.chrome.com

    “Every web document has four separate property trees: transform, clip, effect, and scroll.”

  • Chromium: Life of a Framechromium.googlesource.com
  • Chromium: Viz Architecturechromium.googlesource.com
  • W3C: CSS Compositing and Blending Level 1w3.org
  • web.dev: Stick to Compositor-Only Propertiesweb.dev

Read more

  • Previous

    Critical Rendering Path: Rasterization

    Browser & Runtime Internals / Critical Rendering Path 10 min read

    Rasterization is the process where the browser converts recorded display lists into actual pixels—bitmaps for software raster or GPU textures for hardware-accelerated paths. This stage marks the transition from abstract paint commands to concrete visual data. In Chromium, rasterization is managed by the compositor thread and executed by worker threads, ensuring smooth interactions even when the main thread is saturated with JavaScript or layout work.

  • Next

    Critical Rendering Path: Draw

    Browser & Runtime Internals / Critical Rendering Path 12 min read

    The Draw stage is the final phase of the browser’s rendering pipeline. The Viz process (Visuals) in Chromium takes abstract compositor frames—consisting of render passes and draw quads—and translates them into low-level GPU commands to produce actual pixels on the display.