--- slug: compositor-scheduling type: concept summary: "The cc::Scheduler frame loop (BeginFrame through Commit, Activate, and Draw across two threads and three layer trees) that lets scroll and transform animation run at display refresh rate while the main thread is busy." created: 2026-06-15 updated: 2026-06-14 last_edited: 2026-06-14 last_link_verified: 2026-06-15 related: rendering-pipeline: relation: refines note: "The Rendering Pipeline names the seven RenderingNG stages and defers the per-frame scheduling state machine; this concept is that machine, and explains the 'compositor-only path' the pipeline entry raises and leaves open." rail-performance-model: relation: complements note: "RAIL's Animation budget (the per-frame deadline that scroll and animation must hit) is the budget the impl-frame loop is built to meet without re-entering the main thread." main-thread-starvation: relation: contrasts-with note: "Impl-only frames are why a starved main thread does not freeze compositor-driven scroll and transform animation; the antipattern's damage is bounded by the scheduler's two-thread decoupling." multi-process-architecture: relation: builds-on note: "The BeginFrame source, the Viz display compositor, and the renderer's compositor thread span the renderer and GPU processes; the multi-process decision determines which half of the loop runs where." skia-graphite: relation: complements note: "The Skia Graphite Transition reorganizes the raster work that gates ReadyToActivate and ReadyToDraw inside this loop without changing the loop's structure." --- # Compositor Frame Scheduling > **Concept** > > Vocabulary that names a phenomenon. *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.* > **📝 Where the name comes from** > > *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](main-thread-starvation.md) and the [RAIL Performance Model](rail-performance-model.md) 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](rendering-pipeline.md) 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 `requestAnimationFrame` callbacks, then Style, Layout, Paint, and produces an updated `LayerTreeHost`. 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 `LayerTreeHost` to the compositor thread's `LayerTreeHostImpl`. It is an atomic, mutex-guarded copy run by `ProxyImpl`, 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 to `active_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. ```mermaid 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](rendering-pipeline.md). 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](main-thread-starvation.md). 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::Scheduler` and its state machine drive the sequence. - `cc::ProxyImpl` runs the compositor-thread half and performs the Commit copy. - `cc::ProxyMain` runs the main-thread half. - `cc::LayerTreeHostImpl` owns the three trees and the Draw step. - The `BeginFrameSource` interface in `cc/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. ## 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`](https://chromium.googlesource.com/chromium/src/+/refs/tags/130.0.6723.59/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`](https://chromium.googlesource.com/chromium/src/+/refs/tags/130.0.6723.59/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`](https://chromium.googlesource.com/chromium/src/+/refs/tags/130.0.6723.59/cc/scheduler/scheduler.h) — the `cc::Scheduler` interface and its state-machine entry points; the BeginImplFrame and ScheduledAction surface. - [`cc/trees/proxy_impl.cc`](https://chromium.googlesource.com/chromium/src/+/refs/tags/130.0.6723.59/cc/trees/proxy_impl.cc) — `ProxyImpl`, the compositor-thread half that performs the Commit copy under the main-thread block. - [`cc/README.md`](https://chromium.googlesource.com/chromium/src/+/refs/tags/130.0.6723.59/cc/README.md) — the compositor subsystem overview; the entry point for the `cc/` tree map. - [*Life of a Pixel*, Steve Kobes, Chrome University](https://www.youtube.com/results?search_query=life+of+a+pixel+chrome+university) — the long-form lecture that walks a single frame through the loop in motion. --- - [Next: Surface Aggregation](surface-aggregation.md) - [Previous: Resource Loading Pipeline](resource-loading-pipeline.md)