Compositor Frame Scheduling
The frame loop the Chromium compositor runs every display refresh (BeginFrame, Commit, Activate, Draw) across two threads and three layer trees, and the reason scroll and transform animation keep moving at full frame rate while the main thread is blocked.
cc is the Chromium Compositor: the subsystem in the cc/ source tree that owns the layer trees, the per-frame loop, and the handoff to the GPU process. The scheduling state machine described here is driven by cc::Scheduler; the trace category that surfaces it is cc. This is a distinct subsystem from the Blink main-thread task scheduler (scheduler.postTask, requestIdleCallback, RAILMode) that Main Thread Starvation and the RAIL Performance Model reference; that scheduler orders work on the main thread, while the compositor scheduler decides when each frame’s work crosses between the main thread and the compositor thread.
What It Is
The Rendering Pipeline names the path from network bytes to lit pixels and stops at the point where Paint hands its display list to the compositor thread. Compositor frame scheduling is what happens after that handoff: the loop that turns a stream of vsync ticks into a stream of presented frames, and that decides, on every tick, whether the main thread needs to run at all.
The loop runs on two threads inside the renderer process. The main thread holds the LayerTreeHost: the layer and property data that Blink’s Style, Layout, and Paint stages produce. The compositor thread (often called the impl thread, for the Impl suffix on its classes) holds the LayerTreeHostImpl and runs cc::Scheduler. A frame is the negotiation between the two.
A single frame proceeds through a fixed sequence of states:
- BeginFrame arrives from the BeginFrame source, a signal aligned to the display’s vertical blank (vsync) and delivered from the Viz display compositor in the GPU process. It is the clock the whole loop runs on.
- BeginImplFrame opens the frame on the compositor thread. The scheduler decides what this frame needs.
- BeginMainFrame is sent to the main thread only if the main thread has pending work — a style change, a layout invalidation, a new paint. The main thread runs
requestAnimationFramecallbacks, then Style, Layout, Paint, and produces an updatedLayerTreeHost. If the main thread has nothing to do this frame, this step is skipped entirely. - Commit copies the updated layer and property data from the main thread’s
LayerTreeHostto the compositor thread’sLayerTreeHostImpl. It is an atomic, mutex-guarded copy run byProxyImpl, and it blocks the main thread while it runs. - Activate promotes the freshly committed-and-rasterized data into the tree the compositor draws from, once the tiles it needs have finished rastering.
- Draw walks the active tree, produces a compositor frame (a set of draw quads), and submits it to Viz, which composites it with other surfaces and Swaps it to the screen at the next vsync.
The data moves through a three-tree system on the compositor thread:
active_tree_always exists. It is the tree the compositor draws from and the tree scroll and animation tick on. It is the only tree the Draw step reads.pending_tree_exists only while tiles are rastering. A Commit lands here; the tree waits in this staging state until its required tiles finish, at which point Activate pushes it toactive_tree_.recycle_tree_is the previous pending tree, kept rather than freed so the next Commit reuses its allocation instead of building a tree from scratch. It is mutually exclusive with the pending tree — only one of the two exists at a time.
stateDiagram-v2 [*] --> BeginImplFrame: BeginFrame (vsync) BeginImplFrame --> BeginMainFrame: main thread has work BeginImplFrame --> Draw: impl-only frame BeginMainFrame --> Commit: main update done Commit --> Activate: required tiles rastered Activate --> Draw Draw --> Swap Swap --> [*]
The two paths out of BeginImplFrame are the whole point. When the main thread has work, the frame takes the long path through BeginMainFrame and Commit. When it has none, the scheduler skips straight to Draw. A scroll gesture and a transform/opacity animation both run on the active tree alone, so neither gives the main thread work, and both take this short path: an impl-only frame, a complete, presented frame produced without the main thread running at all.
Why It Matters
The claim that a transform animation “runs at 60 frames per second on the compositor thread no matter what the main thread is doing” is the headline performance fact of the Rendering Pipeline. Compositor frame scheduling is the mechanism that makes it true, and without the mechanism the claim is an assertion the reader has to take on faith.
The impl-only frame is the precise reason a scroll stays smooth while JavaScript is busy. A scroll gesture changes the scroll offset on the active tree; ticking an accelerated animation changes a transform or opacity value on the active tree. Neither needs Style, Layout, or Paint, so neither needs BeginMainFrame. The scheduler produces a frame from the active tree on every vsync, and the main thread (which may be running a 200 ms JavaScript task) isn’t on the critical path. The frame rate the user sees is governed by the compositor thread’s ability to hit the BeginFrame deadline, not by the main thread’s queue.
This is what bounds the damage of Main Thread Starvation. A starved main thread freezes everything that requires it (input event dispatch to JavaScript, DOM mutation, layout-driven animation), but it doesn’t freeze compositor-driven scroll or accelerated animation, because the scheduler has already decoupled those onto the active tree. The decoupling isn’t a side effect; it’s the architectural reason the project can promise that a page stays scrollable even when its scripts misbehave.
The mechanism also explains a cost that surprises people: Commit blocks the main thread. It has to. A single JavaScript call stack may mutate dozens of layers and properties, and the user must never see a half-applied frame, a page where the header has moved but the body hasn’t. The Commit step copies the entire layer and property state atomically, under a mutex, so the compositor thread receives a consistent snapshot of one call stack’s mutations. The price is that the main thread is paused for the duration of the copy. A Commit that copies a very large layer tree is itself a main-thread cost, which is why the project works to keep property changes off the layer tree and on the property trees that Commit can copy cheaply.
For an AI coding agent generating front-end code, the distinction is the difference between an animation that survives a busy main thread and one that does not. Animating a compositor property keeps the work on the active tree and the impl-only path; animating a layout property forces a BeginMainFrame, a Commit, and a raster on every frame, putting the animation back behind the main thread’s queue. The two look almost identical in source and differ by an order of magnitude under load.
How to Recognize It
The loop is directly observable in a chrome://tracing capture. Enable the cc category and the frame loop appears as a sequence of named slices: Scheduler::BeginImplFrame, ProxyMain::BeginMainFrame, ProxyImpl::ScheduledActionCommit, LayerTreeHostImpl::ActivateSyncTree, and LayerTreeHostImpl::DrawLayers. A healthy 60 Hz frame shows these slices completing inside a 16.6 ms window aligned to BeginFrame. A frame that drops shows the cause directly: a BeginMainFrame slice that overruns its budget, or an Activate that waits on tiles that have not finished rastering.
The presence or absence of the BeginMainFrame slice is the recognition cue for an impl-only frame. Scroll a page that has no scroll-linked JavaScript and capture a trace: the cc track shows BeginImplFrame → Draw → Swap on every vsync, with no BeginMainFrame between them. That gap is the impl-only path, visible as a structural absence rather than a labeled event. Add a scroll event listener that reads scrollTop and re-capture, and the BeginMainFrame slices reappear, because the listener forced the main thread back into the loop.
The DevTools Performance panel surfaces the same loop at a higher level. The Frames track at the top renders one entry per presented frame; a frame that exceeds its budget shows red, and expanding it attributes the overrun to a phase: Commit, Composite Layers, or a main-thread stage upstream. The Compositor track below the Main track shows the impl-thread activity that continues even while the Main track is a single long yellow JavaScript block.
The source tree maps the loop to specific classes:
cc::Schedulerand its state machine drive the sequence.cc::ProxyImplruns the compositor-thread half and performs the Commit copy.cc::ProxyMainruns the main-thread half.cc::LayerTreeHostImplowns the three trees and the Draw step.- The
BeginFrameSourceinterface incc/scheduler/delivers the vsync clock.
A regression bisect that lands in cc/scheduler/ or cc/trees/ is a frame-scheduling regression specifically, distinct from a Blink-side pipeline-stage regression.
How It Plays Out
A team building a data-heavy dashboard reports that scrolling a long table is smooth until a background data refresh fires, at which point the scroll briefly jumps. A trace shows the cc track producing impl-only frames cleanly during the smooth phase (BeginImplFrame straight to Draw), but during the jump, BeginMainFrame slices appear and overrun. The cause is a scroll listener that calls getBoundingClientRect() to position a sticky header, forcing a synchronous main-thread Layout on every scroll frame and pulling the scroll off the impl-only path. The fix is position: sticky in CSS, which the compositor handles on the active tree without a listener; the BeginMainFrame slices disappear and the scroll returns to the impl-only path even during the refresh.
A games studio shipping a Chromium-based runtime reports that a loading animation stutters while the level streams in. A trace shows the animation is implemented with a JavaScript requestAnimationFrame loop that updates left each frame. Because left is a layout property, every animation frame forces a BeginMainFrame, a Layout, a Paint, a Commit, and a raster, and those compete with the streaming work for the main thread. Switching the animation to transform: translateX() moves it onto the active tree: the compositor ticks it on the impl-only path, the BeginMainFrame slices vanish, and the animation holds frame rate through the load because the streaming work no longer shares its thread.
A team building a video editor reports that a long Commit shows up as a periodic hitch during editing. A trace attributes the hitch to ScheduledActionCommit slices running 8–10 ms, long enough to blow the frame budget on their own. The cause is a layer tree that grows a new compositor layer for each timeline clip, so the atomic Commit copies a tree that gets larger with every clip added. The fix is to collapse the per-clip layers into a single layer with the clips drawn into one display list, shrinking the tree the Commit must copy. The hitch isn’t at Paint or Raster; it’s the Commit copy itself, and the recognition required attributing it to the scheduler’s atomic-snapshot step rather than to a rendering stage.
Consequences
Naming the frame loop buys several operational properties.
Smoothness becomes decoupled from main-thread load by default, for the right properties only. The impl-only path is automatic for scroll and for transform/opacity animation, and unavailable for anything that touches Layout or Paint. The line between the two is the line between properties that mutate the active tree and properties that require a new main-thread frame. Code that stays on the right side of that line inherits the decoupling; code that crosses it forfeits the decoupling silently, with no error and no warning: only a frame rate that collapses under load.
Atomicity has a cost, and the cost is locatable. Commit blocks the main thread to guarantee that one call stack’s mutations present together. That guarantee is non-negotiable (a partially-applied frame is a correctness bug, not a performance one), so the only lever is the size of the tree Commit copies. A large or rapidly-mutating layer tree makes Commit expensive; keeping changes on the property trees rather than the layer tree keeps it cheap. The scheduler names exactly where this cost lands.
Frame drops become attributable to a phase. A dropped frame is not uniformly “jank.” It is a BeginMainFrame that overran, a Commit that copied too much, an Activate that waited on unrastered tiles, or a Draw that submitted late. The cc-track slices attribute the drop to one of these, and each has a different remediation: trim the main-thread work, shrink the committed tree, fix the raster that gated Activate, or investigate the GPU-process submission. The loop is the vocabulary that turns “the frame dropped” into a specific engineering question.
The two-thread structure is what the project’s smoothness promises rest on. Site Isolation, process consolidation, and the Viz display-compositor architecture all rearrange where the halves of this loop run, but none of them remove the impl-only path. That path is the contract: a page stays scrollable and its accelerated animations keep moving even when its scripts do not. Every architectural decision in this part of the system is constrained by the requirement not to break it.
Notes for Agent Context
When generating an animation or a scroll-linked effect for Chromium, keep it on the compositor’s active tree so the frame scheduler can produce impl-only frames without the main thread. Animate transform and opacity only; never animate left, top, width, height, margin, or any property whose change forces a BeginMainFrame, a Commit, and a raster on every frame, because that puts the animation behind the main thread’s task queue and it will stutter under load. Prefer CSS position: sticky and CSS scroll-driven animations over JavaScript scroll listeners; a scroll listener that reads layout (scrollTop, getBoundingClientRect(), offsetTop) forces a synchronous main-thread Layout on every scroll frame and pulls the scroll off the impl-only path.
Do not let a layer tree grow without bound: each promoted compositor layer is data the atomic Commit must copy from the main thread to the compositor thread under a mutex, blocking the main thread for the duration. Apply will-change: transform to promote a layer only when an element is actually animating, and remove it when the animation ends; a tree full of standing promotions makes every Commit expensive and costs GPU memory besides.
Related Articles
Sources
The canonical primary source is the Chromium project’s Life of a Frame document in the docs/ tree, which gives the BeginFrame → BeginImplFrame → BeginMainFrame → Commit → Activate → Draw → Swap sequence and states that impl-only frames let scroll and animation “proceed at the display’s refresh rate independent of main thread performance.” The How cc Works document, also in the docs/ tree, is the authoritative description of the three-tree system (active_tree_, pending_tree_, recycle_tree_), the atomic mutex-guarded Commit performed by ProxyImpl, and the Activate step that pushes the pending tree to the active tree. The cc/README.md in the source tree describes the compositor’s role and its place between Blink and the Viz display compositor. Steve Kobes’s Life of a Pixel lecture, recorded annually for Chrome University, walks the same loop in motion and is the most thorough public long-form treatment. The RenderingNG article series by Philip Rogers on developer.chrome.com frames the compositor-thread half of rendering and the BeginFrame/Viz relationship for a non-specialist reader.
Technical Drill-Down
docs/how_cc_works.md— the canonical description of the three trees, the atomic Commit, and Activate; the tree-lifecycle section is the load-bearing read for the staging model.docs/life_of_a_frame.md— the per-frame state sequence and the impl-only-frame explanation; the BeginFrame-to-Swap walkthrough.cc/scheduler/scheduler.h— thecc::Schedulerinterface and its state-machine entry points; the BeginImplFrame and ScheduledAction surface.cc/trees/proxy_impl.cc—ProxyImpl, the compositor-thread half that performs the Commit copy under the main-thread block.cc/README.md— the compositor subsystem overview; the entry point for thecc/tree map.- Life of a Pixel, Steve Kobes, Chrome University — the long-form lecture that walks a single frame through the loop in motion.