How the Compositor Rasterizes Layers into Tiles

Fast scrolling paints blank grey squares that fill in a beat later, and the flame chart pins the stall on raster worker tasks fired during the rasterization phase β€” not on layout, not on paint. This guide is part of Compositor Thread and Rasterization, inside the broader Compositing and GPU Acceleration area, and it walks the exact path a painted layer takes from a display list to GPU-backed tiles so you can predict which of your layers will checkerboard before a user ever scrolls into them.

The symptom β€” momentary blank tiles Chrome calls checkerboarding β€” is not a paint bug. Paint already finished on the main thread. What you are watching is the compositor thread failing to rasterize new tiles into GPU memory fast enough to keep up with the scroll offset, and the fix lives entirely in how you size and promote layers.

From Paint List to a Tile Grid

When the main thread finishes paint it does not hand the compositor a bitmap. It hands over a cc::DisplayItemList β€” a recorded list of Skia draw operations β€” for each composited layer. Rasterization is the separate step that replays those draw ops into actual pixels. The critical detail for performance work is that the compositor never rasterizes a whole layer at once. It divides each layer into a grid of tiles, typically 256Γ—256 device pixels, and treats every tile as an independent unit of work.

That partitioning is what lets a 4000px-tall scroll container exist as a composited layer without allocating a 4000px-tall texture up front. Only tiles near the viewport get rasterized; the rest stay as un-rastered records. When you promote an oversized element β€” a full-height list under will-change: transform, first covered in Layer Promotion and Composition β€” you create a PictureLayer whose tile grid spans the element’s entire scroll extent, and every tile that scrolls into view becomes a raster task that must complete inside the frame budget.

A composited layer partitioned into a tile grid A tall PictureLayer is divided into 256 pixel tiles; only tiles inside and near the viewport are rasterized, the rest stay as recorded draw ops. PictureLayer viewport tiles prefetch tiles dashed box = visible viewport green: rastered to texture ready to composite yellow: raster task queued may miss the frame white: recorded draw ops no pixels yet GPU texture pool holds rastered tiles under a memory budget evicts far tiles first eviction on scroll = checkerboard

A Minimal Reproduction

The reproduction promotes a tall list into a single composited layer and puts an expensive paint op β€” a blurred box-shadow β€” on every row. Each 256px tile that scrolls into view must now replay a blur, and blur is one of the costliest Skia ops per pixel.

<style>
  .feed {
    height: 100vh;
    overflow-y: auto;
    will-change: transform; /* BAD: promotes the ENTIRE scroll extent into one PictureLayer */
  }
  .row {
    height: 96px;
    margin: 8px;
    /* large blur radius => every tile intersecting a row pays a blur pass at raster time */
    box-shadow: 0 4px 40px 8px #646b85;
  }
</style>

<div class="feed">
  <!-- 800 rows: the tile grid now spans ~80,000px of raster work -->
  <div class="row"></div>
  <!-- ...repeated 800x... -->
</div>

The will-change: transform line is the trap. It tells Chrome to keep the whole .feed on its own layer so scroll offset can be applied on the compositor thread β€” but the cost is that the layer’s tile grid covers all 800 rows, and the 40px blur makes each new tile expensive to raster. During a fast flick the compositor requests a burst of new tiles, the raster workers fall behind, and the tiles that miss the frame draw as blank checkerboard.

Raster Tasks on the Compositor Thread

Rasterization runs off the main thread. After the main thread commits the layer tree, the compositor thread’s TileManager walks every layer’s tile grid, assigns each dirty tile a priority based on distance from the viewport, and posts a raster task per tile onto a worker pool. Those workers β€” Chrome names them CompositorTileWorker β€” replay the tile’s slice of the display list through Skia into a GPU texture (with out-of-process raster, the draw ops are serialized and executed on the GPU process).

The queue is a priority queue, not FIFO. Tiles inside the viewport are NOW priority; tiles just outside are SOON; tiles far away are EVENTUALLY and may never raster while memory is tight. This is why a slow scroll rarely checkerboards but a fast flick does: the flick moves EVENTUALLY tiles to NOW faster than the workers can drain the queue. The TileManager also enforces a hard memory budget β€” when the GPU texture pool is full it evicts the lowest-priority rastered tiles, which is the same mechanism explored in Debugging GPU Memory Limits in Chrome Compositing. An evicted tile that scrolls back into view must be re-rastered from scratch.

Raster task flow from commit to GPU texture The main thread commits the layer tree, the tile manager prioritizes tiles, worker threads raster them through Skia, and finished textures land in the resource pool for compositing. Main thread paint -> DisplayItemList recorded draw ops commit Compositor thread TileManager prioritize + budget NOW (viewport) SOON (near) EVENTUALLY (far) priority queue Raster worker pool CompositorTileWorker Skia replay -> texture resource pool (GPU textures) budget full -> evict low priority A fast flick promotes EVENTUALLY tiles to NOW faster than workers drain the queue -> blank tiles.

Reading the Trace

Record a Performance profile in Chrome DevTools with the Rasterize Paint and GPU tracks enabled, then flick the list. The tell is a dense band of Rasterize Paint tasks on the raster worker lanes, each one a single tile, with Draw events on the compositor lane stalling behind them. The annotated tree below is what the reproduction produces.

Frame 214  (compositor-driven scroll, target 16.6ms)
β”‚
β”œβ”€ Compositor Thread ─────────────────────────────
β”‚   β”œβ”€ BeginFrame                            0.3 ms
β”‚   β”œβ”€ TileManager::PrepareTiles             1.1 ms   <- 46 tiles moved NOW
β”‚   └─ Draw / SubmitCompositorFrame          0.4 ms
β”‚         └─ 9 tiles missing  ───────────────────  <- CHECKERBOARD drawn here
β”‚
β”œβ”€ Raster Worker 0 ──────────────────────────────
β”‚   β”œβ”€ Rasterize Paint (tile 12,3)           5.8 ms  <- box-shadow blur pass
β”‚   β”œβ”€ Rasterize Paint (tile 12,4)           5.6 ms
β”‚   └─ Rasterize Paint (tile 12,5)           5.9 ms  <- still running at frame end
β”‚
β”œβ”€ Raster Worker 1 ──────────────────────────────
β”‚   β”œβ”€ Rasterize Paint (tile 13,3)           5.7 ms
β”‚   └─ Rasterize Paint (tile 13,4)           5.5 ms
β”‚
└─ Queue depth at vsync: 27 tiles NOW, 4 workers  -> ~40ms to drain

Two workers each burning ~5.8ms per tile cannot clear 27 NOW tiles inside one 16.6ms frame, so SubmitCompositorFrame ships with 9 tiles blank. The root cause is per-tile raster cost (the blur) multiplied by tile count (the oversized promoted layer). Both levers are yours to pull.

The Fix

Two changes: stop promoting the entire scroll extent, and cut the per-tile raster cost. Drop will-change from the scroll container so scrolling uses the browser’s native scroll layer (which rasters incrementally around the viewport instead of gridding the whole extent), and replace the wide blur with a cheap border plus a small, GPU-friendly shadow. Add content-visibility: auto so offscreen rows skip raster entirely until they approach the viewport.

<!-- BEFORE: one giant PictureLayer, expensive blur on every tile -->
<style>
  .feed {
    height: 100vh;
    overflow-y: auto;
    will-change: transform;           /* whole extent tiled + kept resident */
  }
  .row {
    height: 96px;
    margin: 8px;
    box-shadow: 0 4px 40px 8px #646b85; /* 40px blur => costly per-tile raster */
  }
</style>
<!-- AFTER: native scroll layer, cheap raster, offscreen rows skipped -->
<style>
  .feed {
    height: 100vh;
    overflow-y: auto;
    /* no will-change: the browser rasters tiles incrementally around the viewport */
  }
  .row {
    height: 96px;
    margin: 8px;
    border: 1px solid #d8ddea;          /* flat op, no blur pass */
    box-shadow: 0 1px 2px #d8ddea;      /* 2px blur => negligible raster cost */
    content-visibility: auto;           /* skip raster of offscreen rows entirely */
    contain-intrinsic-size: 0 112px;    /* reserve height so the scrollbar stays stable */
  }
</style>

After the change the same flick records Rasterize Paint tasks under 0.6ms each, the NOW queue drains inside the frame, and SubmitCompositorFrame ships zero missing tiles. If you genuinely need the container promoted β€” for a transform animation β€” keep the promotion but still shed per-tile cost and cap the promoted region with CSS containment so the tile grid does not span content that never animates.

The diagram below traces where the raster budget goes in each configuration. The costly path grids and keeps the whole scroll extent resident and runs a wide blur on every tile; the cheap path lets the compositor tile incrementally around the viewport and skips offscreen rows outright.

Raster budget before and after shedding promotion and blur Before, the whole scroll extent is gridded and kept resident with a wide blur per tile; after, a native scroll layer rasterizes only tiles around the viewport with a flat, cheap shadow. Before β€” one giant PictureLayer will-change: transform grid whole scroll extent ~120 tiles kept resident 40px blur pass per tile After β€” incremental raster + content-visibility native scroll layer tiles around viewport only ~12 tiles rasterized 2px shadow flat, cheap

Verification Checklist

Frequently Asked Questions

What exactly is a tile in the Chrome compositor?

A tile is a fixed-size rectangular unit of a composited layer, usually 256Γ—256 device pixels, that the compositor rasterizes independently. Splitting a layer into tiles lets Chrome rasterize only the region near the viewport and manage GPU memory tile-by-tile instead of allocating one texture for the whole layer.

Why do I see blank grey squares while scrolling fast?

That is checkerboarding. It happens when tiles scroll into view faster than the raster worker threads can rasterize them into GPU textures, so the compositor submits the frame with those tiles still empty. It points to high per-tile raster cost, an oversized promoted layer, or GPU memory eviction β€” not to a paint bug on the main thread.

Does will-change: transform make scrolling faster?

Not for a large scroll container. It promotes the element to its own layer and tiles the entire scroll extent, so every tile that scrolls in becomes a raster task. Native scrolling without the hint lets the browser rasterize incrementally around the viewport, which is usually cheaper. Reserve will-change: transform for elements you actually animate with a transform.

On which thread does rasterization run?

Rasterization runs on the compositor’s raster worker pool (CompositorTileWorker), off the main thread. The main thread only records the display list during paint and commits the layer tree; the compositor thread’s TileManager prioritizes tiles and the workers replay the draw ops through Skia into GPU textures.

How does content-visibility reduce raster work?

content-visibility: auto lets the browser skip layout, paint, and rasterization for a subtree while it is far offscreen. The tiles for those rows are never generated until the element approaches the viewport, which shrinks the number of NOW raster tasks during a fast scroll and keeps the GPU texture pool within budget.