Concepts — Compositor and GPU

Phase 4 · Spec area §15. Prerequisites: bi-09.


0. Orientation

The 60-second version

The compositor exists to answer one question: can we produce a frame without the main thread?

When the answer is yes, scrolling and transform/opacity animations stay smooth through arbitrary main-thread jank. When the answer is no, everything the user perceives is hostage to whatever JavaScript is running. Almost every conversation about "smooth" is really about which of those two regimes you are in — and what pushed you out of the good one.

The two failure modes to be able to name

1. Scroll fell back to the main thread. Something forced it: a non-passive wheel/touch listener, a main-thread-scrolling reason, an unsupported effect. The useful question is never "why is scroll janky" but "what forced it back?" — that has a specific, findable answer.

2. The tiles were not ready. The compositor could produce a frame and had nothing rastered to put in it, so the user sees blank areas — checkerboarding. That is not a main-thread problem at all, and optimising JavaScript will not touch it. fw-10's blank rows on fast scroll are the application-level analogue.

Distinguishing those two is most of the diagnostic value in this module, and they have completely different fixes.

"The GPU makes it fast" is not an explanation

The GPU rasterises and composites; it does not make your work disappear. What actually changes is which work is needed: a composited transform needs neither layout, nor paint, nor raster — only a new frame assembled from tiles that already exist. The win is work avoided, not work accelerated.

What you should be able to do after this module

CapabilityEvidence you have it
Diagnose jank correctlySeparate "main thread blocked" from "tiles not ready" before profiling
Name what forced scroll backGive the specific reason, not "scroll is slow"
Budget a frameItemise 16.7 ms across the stages that must fit in it
Explain {passive: true}As an author promise unlocking an otherwise-impossible optimisation

1. Why a Principal Engineer needs this

1. This is the layer that decides whether your page feels smooth while the main thread is busy. Compositor-driven scrolling and compositor-driven animations are the reason a janky page still scrolls — and the reason a single non-passive listener can destroy that.

2. The frame budget is not yours. 16.7 ms at 60 Hz is the whole budget, shared between your JS, style, layout, paint, commit, raster, and the GPU's own work. Engineers who believe they have 16 ms of script time consistently ship jank.

3. It is where "it's fast on my machine" dies. Raster and GPU costs scale with pixels and device capability, not with your code's complexity. A retina laptop and a low-end phone differ by an order of magnitude in exactly this stage.


2. Mental Model

2.1 Two threads, two trees

RENDERER MAIN THREAD                 COMPOSITOR (impl) THREAD
  style, layout, paint      commit
  main-thread layer tree  ─────────►  pending tree
                                        |  raster tiles (worker pool / GPU process)
                                        v  activate
                                      active tree
                                        |
                                        v  produce a CompositorFrame
                                     GPU PROCESS (viz) -> display -> vsync

The essential asymmetry:

The compositor thread can produce frames from the active tree without the main thread.

That is why scrolling and transform/opacity animations survive a blocked main thread, and it is the single most important fact in this module.

2.2 Commit, activate, draw

  • Commit — main thread hands the compositor an updated tree. Main thread is blocked briefly.
  • Activate — the pending tree becomes active once its tiles are ready to draw.
  • Draw — the compositor produces a frame; the display compositor (viz, in the GPU process) composites all sources and presents at vsync.

The pending/active split exists so that half-rastered content is never shown. A simpler design — draw whatever is ready — would produce visible tearing and partially-updated frames. That is the §46 entry for this module.

2.3 Tiles and raster

Layers are divided into tiles, rastered independently and prioritised by proximity to the viewport. This is why:

  • scrolling fast can show checkerboarding — you outran raster;
  • a huge layer costs memory proportional to its area, not its visible part;
  • raster happens off the main thread, and may be GPU-accelerated.

2.4 Scrolling, precisely

Scrolling is compositor-driven unless something forces main-thread involvement. The usual causes:

  • a non-passive wheel/touchstart listener that might call preventDefault(),
  • certain position: fixed/sticky and background-attachment: fixed situations,
  • scroll-linked effects implemented in JS scroll handlers.

{ passive: true } is a promise that you will not call preventDefault(), which lets the compositor scroll without asking the main thread. This is the clearest example in the platform of an author-supplied guarantee unlocking a fast path — and the reason browsers made passive the default for some listeners.

2.5 Compositor-driven animations

An animation can run on the compositor if it only changes property-tree values (transform, opacity, and some filters). Anything that changes layout or requires re-recording the display list must involve the main thread every frame. This is bi-09's property-tree argument, cashed out.

2.6 The frame budget

60 Hz  -> 16.7 ms per frame
120 Hz ->  8.3 ms per frame

Within that: input processing, rAF callbacks, style, layout, pre-paint, paint, commit, raster, GPU draw, and presentation. Your script is one term. On a 120 Hz device the budget halves while raster costs do not — which is why high-refresh displays expose jank that 60 Hz hid.


3. Under the Hood

ConcernWhere
Compositor//cc — layer trees, tiling, raster scheduling, animation
Display compositor, command buffer//components/viz, //gpu
Blink side of compositingcore/paint/ + the compositing decisions in pre-paint
Scheduling framesthe compositor scheduler in //cc

Remember //cc runs in the renderer process across two threads, while viz/GPU work runs in the GPU process (bi-02). Conflating "compositor" with "GPU process" is the standard error here.


3.5 Deep dive: the cc vocabulary, from the source glossary

cc/README.md carries a glossary. These are the terms you need to read a trace or a bug report.

TermDefinition
Layera conceptual piece of content with a known position relative to the viewport. Main thread only.
LayerImplthe same thing, on the compositor thread
Active treethe layers + property trees used to submit a CompositorFrame. Composited effects — scrolling, pinch, animations — are done by modifying the active tree
CompositorFramea set of RenderPasses (each a list of DrawQuads) plus metadata — "the instructions for how to draw an entire scene presented in a surface"
DrawQuadone primitive draw instruction (a textured rect, a solid colour, …)
RenderPassa group of quads drawn to an intermediate target
ElementIDa stable identifier across updates, chosen by cc's clients; Blink uses it to identify the object responsible for a composited animation
DirectRendererabstraction for drawing an aggregated CompositorFrame to a physical output; backends are GL, Skia, or Software
CopyOutputRequesta request to copy part of the output; forces a separate RenderPass

Three observations that repay attention:

1. Layer vs LayerImpl is the thread boundary made into types. You can tell which thread a piece of cc code runs on from the type names alone. That is a design worth stealing: when a boundary matters, encode it in the type system rather than in a comment.

2. "Composited effects are done by modifying the active tree." That single sentence is the mechanism behind compositor-driven scroll and transform/opacity animation. The compositor is not re-deriving anything from Blink — it is mutating its own tree and re-submitting. This is why those effects survive a blocked main thread.

3. A CompositorFrame is instructions, not pixels. Quads with transforms and texture ids. The GPU process executes them. So "compositing" is closer to issuing a display list than to copying bitmaps — the same record-then-execute pattern as bi-09's display items, one layer down.

ElementID deserves a note of its own. It is how Blink says "this animation belongs to that thing" in a way that survives commits, layer reshuffling, and tree rebuilds. Every system that hands work to an asynchronous consumer needs a stable identity that outlives the structures on both sides — key in fw-02, ElementID here. Same problem, same shape of answer.


3.6 Deep dive: the frame pipeline, stage by stage

BeginFrame  ->  main thread: rAF, style, layout, prepaint, paint
            ->  COMMIT  (main thread blocked briefly; hands over the layer tree)
            ->  compositor: pending tree
            ->  RASTER (worker pool / GPU)
            ->  ACTIVATE (pending -> active, only when tiles are ready)
            ->  DRAW: submit CompositorFrame
            ->  viz (GPU process): aggregate frames from all sources
            ->  DirectRenderer draws to the physical output
            ->  PRESENT at vsync

Where things go wrong, per stage:

Stage overrunsSymptomUsual cause
main-thread framejank, input delaylong tasks, forced sync layout, heavy rAF
commitperiodic hitcheshuge layer trees, many property changes
rastercheckerboarding on scrollexpensive paint, large layers, many tiles
activatestale contenttiles not ready; raster outran
draw / aggregatedropped frames on a busy systemGPU contention, too many surfaces

Aggregation is the part application engineers never see. viz aggregates CompositorFrames from multiple sources — every renderer, plus browser UI — into one frame for the display. That is why an out-of-process iframe can composite independently, and why a misbehaving GPU process affects every tab at once.


3.7 Deep dive: tiles, and why scrolling can show nothing

Layers are divided into tiles rastered independently and prioritised by proximity to the viewport. There is a raster budget; tiles outside it wait.

This is why:

  • Checkerboarding (blank regions during fast scroll) is correct behaviour: the compositor drew a consistent frame using what was ready, rather than blocking. The alternative — stall until raster finishes — is worse, and the choice is deliberate.
  • A very large layer costs memory proportional to its area, not its visible portion — so will-change: transform on a 10,000px-tall element is expensive even though you can see 800px.
  • Raster happens off the main thread, and may be GPU-accelerated. GPU raster is usually faster; the CPU path exists because drivers vary enormously and some content rasters better on CPU.

Damage tracking

The compositor tracks a damage rect — the region that actually changed — so the GPU redraws only that. This is why a small blinking cursor does not cost a full-screen redraw. When you see a full-screen damage rect in a trace for a tiny change, something has invalidated a whole layer, and that is a bug worth chasing.


3.8 Deep dive: what actually forces scroll onto the main thread

Compositor scrolling is the default. It is lost when the compositor cannot answer "may I scroll this without asking?" — and bi-09's hit-test data is how it tries.

Causes, roughly in order of how often they appear in real apps:

  1. Non-passive wheel / touchstart / touchmove listeners — the compositor must wait to see whether you call preventDefault().
  2. scroll event handlers that write styles — the handler runs on the main thread, one frame after the scroll it reacts to. Structurally late; not fixable by optimising the handler.
  3. background-attachment: fixed and some position: fixed/sticky configurations.
  4. Scroll-linked effects in JS generally.

{ passive: true } is a promise that you will not call preventDefault(). It is the clearest example in the platform of an author-supplied guarantee unlocking a fast path — the same shape as key (fw-02), contain (bi-07), and sideEffects (fw-07). Browsers eventually made some listeners passive by default because the guarantee was almost always true and almost never declared.

The modern alternatives, which move the effect to the compositor instead of the main thread: IntersectionObserver for "is it visible", CSS scroll-driven animations (animation-timeline: scroll()) for scroll-linked motion, position: sticky for stickiness, and CSS scroll snap for snapping. Each replaces a scroll handler with something declarative that the compositor can evaluate itself.


3.9 Deep dive: the frame budget, itemised

60 Hz  -> 16.7 ms      120 Hz -> 8.3 ms      144 Hz -> 6.9 ms

Within one frame the main thread may need to do: input dispatch, rAF callbacks, ResizeObserver and IntersectionObserver delivery, style recalculation, layout, pre-paint, paint, and commit. The compositor then needs: raster (possibly), activate, draw, submit. The GPU process needs: aggregate and present.

Your JavaScript is one term in that sum. The common failure is a team optimising script time from 8 ms to 5 ms while style+layout costs 9 ms, and reporting no improvement.

Two consequences that are easy to state and hard to internalise:

  • High-refresh displays halve the budget but not the costs. Raster, paint, and GPU work do not get cheaper at 120 Hz. A page that is comfortable at 60 Hz can be visibly janky at 120 Hz, and the fix is usually reducing work rather than yielding differently.
  • Skipping a frame is not proportional degradation. Miss by 1 ms and you lose a whole frame — 16.7 ms of latency for 1 ms of overrun. Frame budgets are cliffs, not slopes, and this is why p95 matters far more than mean.

3.10 Deep dive: what "the GPU makes it fast" actually means

The folk explanation — "transform is GPU-accelerated, so it is fast" — is wrong in a way that produces bad decisions.

The truth is structural, not hardware:

  1. transform and opacity map to property-tree node changes (bi-09).
  2. A property-tree change does not require re-recording display items.
  3. Not re-recording means not re-rastering.
  4. The compositor can therefore produce a new frame from already-rastered tiles.
  5. That path does not need the main thread at all.

The GPU is involved, but the reason it is cheap is that stages were skipped, not that a processor is fast. This matters because it predicts the exceptions correctly: animating filter: blur() is also composited but is genuinely expensive, because the effect itself costs GPU work per frame. A purely hardware explanation cannot tell you that; a pipeline explanation can.


4. Anti-Patterns

Non-passive scroll listeners. Usually accidental; measurably destructive.

JS-driven scroll effects. scroll handlers that write styles run on the main thread, one frame late by construction. Prefer IntersectionObserver, scroll-driven animations, or CSS.

Giant composited layers. Memory scales with area.

will-change as a blanket fix (see bi-09).

Testing only on desktop at 60 Hz. Raster and GPU costs are where device differences live.


5. Trade-offs

Compositor-driven smoothness vs correctness. The compositor draws stale but consistent content while the main thread catches up. That is why scrolling can reveal not-yet-painted areas: the alternative is blocking on the main thread, which is worse.

Pending/active trees vs latency. Never showing partial content costs a frame of latency relative to drawing whatever is ready.

More layers vs fewer. Fewer repaints, more memory and composite cost.

Raster on GPU vs CPU. GPU raster is faster for many workloads and worse for some; drivers vary enormously, which is why there is a fallback path at all.


6. Lab

Your mini-browser will not have a real compositor; the lab is measurement and reasoning.

  1. Budget accounting. Build a page with a rAF animation and measure, from a trace, how much of each frame is script vs style/layout vs paint vs raster vs GPU. Produce a stacked chart.
  2. Passive vs non-passive. Same page, one non-passive touchstart/wheel listener. Measure scroll latency with and without. Explain the mechanism, then explain the number.
  3. Compositor vs main-thread animation. Animate the same visual effect via transform and via top. Block the main thread with a 500 ms task mid-animation. Record both. Describe what the user sees in each case.
  4. Tile/checkerboard. Make a very tall page with expensive content and fling-scroll it on a throttled profile. Capture checkerboarding. Explain it in terms of tiles and raster priority.
  5. 120 Hz thought experiment. Take your frame budget chart from (1) and recompute against 8.3 ms. Which term breaks first?

Deliverable: the four measurements plus one paragraph on which of them would change most on a low-end Android device, and why.


7. Failure Lab

  1. Add a non-passive wheel listener that does nothing. Show the cost.
  2. Animate box-shadow (paint-heavy) vs transform. Compare frames.
  3. Force a giant layer (will-change: transform on a very large element). Measure memory.
  4. Write a scroll handler that writes a style each event. Demonstrate the one-frame lag, then fix it with a compositor-friendly approach and prove the lag is gone.

8. Debugging Exercise

  1. Perfetto: capture a scroll. Identify the compositor thread, the raster workers, and the GPU process. Follow one frame's flow arrows end to end.
  2. Find a frame that was dropped. Determine which stage overran, from the trace alone.
  3. DevTools: use layer borders and the rendering panel to find an unexpected composited layer. Determine what created it.
  4. Determine whether a given animation is running on the compositor. State your evidence.

9. References

  • //cc documentation in-tree; //components/viz docs.
  • Life of a Pixel; the Chromium rendering team's compositor documentation.
  • CSS Transforms, Web Animations, Scroll-driven Animations specs.
  • EventTarget.addEventListener passive listeners (DOM spec) and the interventions that made some listeners passive by default.

Core terminology

TermDefinition
ccChromium's compositor; runs on the renderer's main and impl threads
Impl threadThe compositor thread; produces frames from already-painted content
CommitHanding the main thread's layer/property state to the impl thread
ActivationThe impl thread adopting a newly committed tree once its tiles are ready
Pending / active treeThe tree being prepared versus the one currently drawing
TileA unit of rasterised content; raster and upload happen per tile
CheckerboardingVisible blank regions where tiles are not yet rastered
Raster scaleThe resolution content is rastered at; wrong scale means re-raster on zoom
LayerisationDeciding what gets its own composited layer
Main-thread scrolling reasonA specific, enumerable cause forcing scroll off the compositor
Passive listener{passive:true} — a promise not to preventDefault()
Viz / display compositorThe GPU-process side that assembles frames from all sources
Command bufferThe serialised GL/Vulkan command channel to the GPU process
BeginFrameThe signal starting frame production, driven by the display's vsync
Frame budget16.7 ms at 60 Hz, 8.3 ms at 120 Hz — for the whole pipeline
Damage / invalidation rectThe region that actually changed and must be redrawn

Mental models

The compositor's independence is conditional, and the condition is the diagnosis. Scroll is off the main thread until something forces it back. That something is enumerable, so "why is scroll janky?" should always be refined to "which main-thread scrolling reason applies?"

{passive: true} is an author promise unlocking an otherwise-impossible optimisation. The browser cannot start scrolling until it knows you will not call preventDefault() — and it cannot know that without running your listener, which requires the main thread. Declaring passive resolves the impossibility. This is the same pattern as key (fw-02), contain (bi-07), sideEffects (fw-07) and aria-setsize (fw-10): the runtime cannot derive a fact it needs, so the platform lets the author promise it.

Two distinct failure modes, two distinct fixes. Main-thread blockage is a scheduling problem (bi-11, fe-01). Checkerboarding is a raster-throughput problem — more tiles than can be prepared in time — and no amount of JavaScript optimisation addresses it.

The GPU avoids work; it does not accelerate yours. A composited transform requires no layout, no paint and no raster — just a new frame from existing tiles. Saying "the GPU makes it fast" hides the actual mechanism, which is what you need in order to predict which properties qualify.

Frame budget is for the whole pipeline. 16.7 ms at 60 Hz covers JS, style, layout, paint, composite — not 16.7 ms of yours. At 120 Hz it is 8.3 ms, and the mid-tier device your users hold is typically 4–6× slower than your laptop, which is why fe-01's benchmark throttles CPU 6×.

More layers is not better. Each costs GPU memory and composite work, which is why broad will-change is a regression (fe-05) rather than a hint.


Common misconceptions

  1. "Scrolling is always off the main thread." It is until something forces it back, and that something has a name you can find.

  2. "Janky scrolling means slow JavaScript." It may mean tiles were not rastered in time — a completely different problem with a completely different fix.

  3. "{passive: true} is a performance micro-optimisation." It is what makes off-main-thread scroll possible at all for that listener, by resolving an impossibility.

  4. "The GPU makes rendering fast." It rasterises and composites. The win from compositor-only properties is the pipeline stages skipped, not GPU speed.

  5. "will-change: transform everywhere is a safe optimisation." It allocates layers and GPU memory, and degrades what it was meant to improve.

  6. "The frame budget is 16.7 ms of my JavaScript." It is 16.7 ms for the entire pipeline, on a device typically several times slower than yours.

  7. "Checkerboarding is a bug." It is the compositor correctly choosing to show a frame late-ish with missing content rather than not showing one at all.

  8. "Compositing is free." Each layer has memory and per-frame cost; the question is always whether it is cheaper than the work it avoids.


Interview talking points

  • "The compositor exists to answer one question: can we produce a frame without the main thread? Everything about 'smooth' comes down to which regime you're in, and the useful question is never 'why is scroll janky' but 'what forced scroll back onto the main thread' — that has a specific, enumerable answer."
  • "I separate two failure modes before profiling anything. Main-thread blockage is a scheduling problem. Checkerboarding — blank regions where tiles weren't rastered in time — is a raster throughput problem, and no amount of JavaScript optimisation touches it."
  • "{passive: true} is my favourite example of a pattern that runs through this whole track. The browser can't start scrolling until it knows you won't preventDefault(), and it can't know that without running your listener on the main thread. The author promise resolves an impossibility — same shape as list keys, contain, sideEffects, and aria-setsize."
  • "'The GPU makes it fast' isn't an explanation. A composited transform needs no layout, no paint and no raster — just a new frame from tiles that already exist. The win is work avoided, not work accelerated, and that's what lets you predict which properties qualify instead of memorising a list."
  • "The frame budget is 16.7 milliseconds for the entire pipeline, not for your JavaScript — and on a mid-tier device that's typically four to six times slower than the laptop you developed on. That's why I want benchmarks throttled before I'll believe them."

10. Principal Engineer Review

  1. Explain why a page with a blocked main thread still scrolls, in four sentences, mechanically.

  2. {passive: true} is a promise from the author that unlocks a browser fast path. Name two other places in the web platform with the same shape, and what makes the pattern work.

  3. Your app is smooth at 60 Hz and janky at 120 Hz. Give the likeliest causes in order and how you would confirm each.

  4. The pending/active tree split costs a frame of latency to avoid showing partial content. Argue the opposite trade-off; what product would choose it?

  5. A team proposes implementing a parallax effect with a scroll listener. Give the mechanism- level objection and two alternatives, with their limitations.

  6. Compositing decisions are heuristic. Design the API you would give authors instead. What would go wrong when they use it?

  7. Checkerboarding is user-visible incorrectness that browsers ship deliberately. Justify it. Under what conditions would blocking be better?

  8. You have one week to improve scroll performance across a large app. What do you measure first, and what is the single most likely finding?

  9. Raster can happen on CPU or GPU, with a fallback path. Argue for removing the CPU path. What breaks, and for whom?

  10. A stakeholder asks for a "performance budget" for a new feature. Express it in frame-budget terms, and say which parts of the budget your team does not control.


Connections to other modules

  • bi-09 — property trees and display items are what the compositor consumes.
  • bi-11 — the scheduler that decides when frames are produced and what competes with them.
  • bi-02 — the input path, and why the compositor handles scroll before the renderer main thread.
  • fe-01 — the frame budget from outside, and "if the composited animation still runs, the main thread is blocked" as a first-line diagnostic.
  • fe-05 — will-change over-promotion and accidental stacking contexts.
  • fw-10 — blank rows on fast scroll are checkerboarding at the application level; overscan is the analogue of tile prefetch.
  • fw-04 — the frame budget that makes "yield" a measured constraint rather than a word.