Reducing Raster Cost on Large Scrolling Pages

On a tall feed or documentation page, fast scrolling leaves blank white bands that fill in a beat late — the compositor is rasterizing new tiles just-in-time and the raster workers cannot keep pace with the scroll velocity. This is a rasterization problem, not a layout or scripting one: the geometry is settled, but turning each incoming tile’s paint recording into a bitmap costs too much per tile. This guide is part of Compositor Thread and Rasterization, within the Compositing and GPU Acceleration pillar, and focuses narrowly on the per-tile raster budget rather than layer count or layout thrash.

How the Compositor Rasterizes a Scrolling Page

When you scroll, the visible rectangle moves over a composited layer whose full height may be 40,000px, but the browser never holds a 40,000px bitmap. The layer is cut into a grid of tiles (256×256 or 512×512 device pixels in Chromium), and only tiles near the viewport are turned into pixels. Each layer carries a paint recording — a display list of Skia draw ops produced by the paint phase — and rasterization replays that recording, clipped to the tile, into a GPU-backed bitmap. The TileManager on the compositor thread orders tiles by priority (visible now, will be visible soon, eventually) and hands raster tasks to a pool of raster worker threads; the main thread is not involved at all.

The failure mode is throughput. Scrolling at 3,000px/s across 512px tiles means a new row of tiles must be rasterized roughly every 170ms of content, but the compositor wants each tile ready before it enters the viewport. If a single tile takes 12ms to raster and four new tiles appear per frame, the raster pool falls behind, the tile priority queue backs up, and the compositor draws whatever it has — including empty “checkerboard” tiles. The cost per tile is the lever, and it is dominated by how expensive the paint recording is to replay.

Two properties of this model matter for the fix. First, the compositor keeps a small ring of pre-rastered tiles beyond the visible edge (the “interest area”), so slow scrolling hides the cost while a hard flick outruns the buffer and exposes it. Second, tiles are cached: a tile that has not been invalidated is reused across frames rather than re-rastered, which is why a static feed only pays the raster bill once per tile as it first scrolls into range. The problem is purely the width of that first-raster spike, and every technique below either narrows the paint recording or removes tiles from the set that must ever be rastered.

Tile rasterization pipeline for a scrolling layer A tall composited layer is cut into tiles, prioritized by the TileManager, and rasterized by a worker pool into GPU bitmaps. Compositor thread — no main-thread involvement Composited layer green = visible, yellow = soon TileManager priority queue visible > soon > eventually Raster worker pool worker 1 worker 2 replays display list clipped per tile GPU tile bitmap

Minimal Reproduction

The smallest reliable trigger is a long list where every row carries an expensive paint op. A blurred box-shadow is the classic offender: the blur is a convolution the rasterizer must evaluate for every pixel of the shadow, on every tile the card touches.

<!-- 2,000 cards, each with a large blurred shadow -->
<div class="feed">
  <!-- repeated 2000× -->
  <article class="card"></article>
</div>

<style>
.card {
  margin: 12px;
  border-radius: 16px;
  /* ❌ 40px blur radius: rasterizer runs a wide convolution per pixel, per tile */
  box-shadow: 0 8px 40px rgba(35, 40, 58, 0.35);
}
</style>

The page scrolls fine at rest and even slowly. Flick it hard on a trackpad and the bottom third of the viewport flashes blank as fresh tiles arrive un-rastered. The box-shadow blur radius is the single bad property here — drop it to 0 and the checkerboarding disappears even though nothing else changed.

Why Expensive Paint Properties Multiply Raster Cost

Raster cost is not “one cost per element” — it is the sum of draw-op costs for every tile the element overlaps. A card with a 40px-blur shadow spanning a tile boundary is rastered twice, once per tile, and each raster runs the blur kernel. Blur is O(radius) per pixel (or O(radius²) for a naive box blur), so a 40px radius is roughly five times the work of an 8px one before you even count how many tiles it smears across. Other operations scale the same way: backdrop-filter reads back the already-composited content under the element, large radial and conic gradients evaluate a per-pixel function, and non-integer border-radius on a big surface forces an anti-aliased mask. GPU rasterization (Skia’s Ganesh/Graphite backend) offloads much of this to shaders, but wide blurs and backdrop filters still stall the raster worker because they need multi-pass render targets.

Pipeline phase Paint op Per-tile cost driver
Rasterization Solid fill / flat color Cheap — single write per pixel
Rasterization Small box-shadow (≤8px blur) Moderate — narrow convolution
Rasterization Large box-shadow (≥30px blur) Expensive — wide convolution × tiles spanned
Rasterization backdrop-filter: blur() Very expensive — render-target readback per frame
Rasterization Large radial-gradient Expensive — per-pixel gradient eval over the tile
Cheap versus expensive paint cost per tile A flat-fill card rasters in one pass while a wide-blur shadow card spans two tiles and runs a convolution on each. Flat fill — cheap card fill 2 tiles · 1 write/pixel raster cost: 1.2ms 40px blur shadow — expensive card + blur 2 tiles · convolution/pixel raster cost: 9.8ms Same layout, same tiles — the paint recording is what differs

The Trace Signature

Record a performance trace, scroll hard, and look at the Raster track (the compositor’s worker threads), not the main thread. A raster-bound page shows long Rasterize Paint tasks packed back-to-back with the main thread nearly idle, plus dropped frames flagged on the Frames track.

[Scroll gesture @ 3000px/s]
Main thread ............ idle (2.1ms/frame)   ← not the bottleneck
Compositor thread
└─ Frame 1  Budget 16.6ms
    ├─ Draw + Swap (ready tiles)
    └─ Missing tiles: 4  ← checkerboard drawn
Raster workers (pool of 4)
├─ worker 1: Rasterize Paint (9.8ms)  ← box-shadow blur
├─ worker 2: Rasterize Paint (9.4ms)  ← box-shadow blur
├─ worker 3: Rasterize Paint (10.1ms) ← box-shadow blur
└─ worker 4: Rasterize Paint (9.6ms)
   backlog: 11 tiles queued, cannot finish before Draw

The tells: near-zero main-thread work, Rasterize Paint tasks well above 6ms each, a non-empty tile backlog, and “Missing tiles” on the frame. That trio means you are paying too much per tile — the fix is to make the recording cheaper or to stop recording offscreen content at all, not to touch JavaScript.

Cutting Raster Work with content-visibility and Containment

There are two independent levers. First, make each tile cheaper to raster by replacing wide blurs with pre-baked or narrower effects. Second, stop rastering content the user cannot see with content-visibility: auto, which lets the browser skip paint and raster for offscreen subtrees entirely, backed by CSS containment so the skipped subtree cannot affect anything outside it. Combined, they collapse a 2,000-card feed from thousands of expensive tiles down to the handful near the viewport. Both fixes stay on the compositor’s terms and avoid over-promoting elements into their own layers, which would trade raster cost for the GPU memory ceiling.

/* ❌ BEFORE — every card blurs on every tile, and all 2000 paint even offscreen */
.card {
  border-radius: 16px;
  box-shadow: 0 8px 40px rgba(35, 40, 58, 0.35); /* wide convolution per tile */
}

/* ✅ AFTER — narrow shadow + skip raster for off-screen rows */
.card {
  border-radius: 16px;
  /* tighter blur: ~4× less convolution work per pixel */
  box-shadow: 0 2px 6px rgba(35, 40, 58, 0.28);
}

.card {
  /* skip layout, paint AND raster while the row is outside the viewport */
  content-visibility: auto;
  /* reserve height so the scrollbar stays stable and no reflow fires on reveal */
  contain-intrinsic-size: auto 220px;
}

content-visibility: auto makes the browser treat each card’s subtree as skipped until it approaches the viewport; a skipped subtree is not painted, so no display list is recorded and no tile for it is ever rastered. As you scroll, the browser rasters only the rows entering view, and the raster backlog empties because the per-frame work is now bounded by the viewport height, not the document height. The contain-intrinsic-size placeholder keeps the scroll offset honest so revealing a row does not trigger a layout that shifts the page. If a card genuinely needs a soft shadow, a single-color filter: drop-shadow() on a promoted layer can be cheaper than a per-tile box-shadow, but measure it — see promoting layers safely before reaching for will-change.

Raster work before and after content-visibility Before, all rows raster; after, only rows near the viewport raster while offscreen rows are skipped. Before — all rows raster 6 rows rastered / frame viewport After — offscreen skipped skipped skipped skipped skipped 2 rows rastered / frame

Verification Checklist

Frequently Asked Questions

Is raster cost a main-thread or compositor-thread problem?

Rasterization runs on the compositor’s raster worker threads, not the main thread. In a trace you will see the main thread nearly idle while Rasterize Paint tasks dominate the raster workers. That is why touching JavaScript or debouncing scroll handlers does nothing for this symptom — the fix has to lower per-tile paint cost or skip offscreen tiles.

Why does a box-shadow hurt scrolling more than a solid background?

A blurred box-shadow runs a convolution whose width scales with the blur radius, evaluated per pixel and repeated for every tile the shadow overlaps. A solid fill writes each pixel once. A 40px blur can be roughly five to ten times more expensive per pixel than a flat fill, and because it smears across tile boundaries the browser pays that cost on multiple tiles as they scroll into view.

Does content-visibility auto remove raster cost or just defer it?

It removes it while the subtree is offscreen. A skipped subtree is not painted at all, so no display list is recorded and no tiles are rastered for it. The cost returns only when the row approaches the viewport and the browser renders it just-in-time, which keeps per-frame raster work bounded by the viewport rather than the full document height.

Should I promote every card to its own layer to speed up raster?

No. Promoting each card gives it a dedicated backing store and burns GPU memory fast; on a 2,000-card feed you will hit the compositing memory ceiling long before it helps. Prefer cutting per-tile paint cost and using content-visibility: auto. Reserve layer promotion for a handful of elements you actually animate.

How do I confirm rasterization is the bottleneck and not layout?

Record a performance trace and scroll. If the main thread is busy with Layout or Recalculate Style, it is a layout problem; if the main thread is idle but the raster workers show long Rasterize Paint tasks with a tile backlog and missing tiles on the Frames track, it is a raster problem.