Paint Invalidation and Regions

What Paint Invalidation Is

After layout completes, the browser determines which regions of the screen need to be redrawn. This determination is called paint invalidation: the rendering engine marks “dirty” rectangles where pixels no longer match the computed layout and style. Only dirty regions are re-rasterized; clean regions reuse cached textures.

Efficient paint invalidation keeps dirty rectangles tight — scoped to the exact bounds of the changed element. Inefficient invalidation produces large dirty rectangles that expand to cover ancestors, siblings, or the entire viewport, forcing rasterization work proportional to pixel area rather than to the number of changed elements.

Within the broader Layout and Paint Optimization framework, paint invalidation is the stage immediately after layout and before the compositor submits the frame.

Dirty-region invalidation expanding into a full layer repaint A single mutated element marks a tight dirty rect; an unbounded box-shadow expands it to cover the whole compositor layer, forcing a full re-rasterization. Tight dirty rect layer changed box-shadow Expanded dirty rect whole layer dirty changed Compositor re-rasterize upload tile

The mechanics of marking and propagating these dirty rects build on Reflow and Repaint Triggers, and skipping invalidation for off-screen subtrees entirely is the job of Content Visibility and Rendering Subtrees.

In This Section

Two focused guides go deeper on the highest-leverage techniques below:

Identifying Over-Broad Invalidation

DevTools workflow:

  1. Open the Rendering tab (Esc → Rendering in DevTools).
  2. Enable Paint Flashing. Green overlays appear over any region being repainted.
  3. Trigger the interaction. Full-viewport green flashes indicate uncontained invalidation.
  4. Enable Layer Borders. Blue borders show compositor layer boundaries. If a paint flash covers an entire layer that includes elements that did not change, the layer is too large.

For a frame-by-frame walkthrough of interpreting the green overlays and correlating each flash back to a specific Paint trace event, see Debugging Paint Flashing in DevTools.

In the Performance panel:

Filter the Main thread for Paint, UpdateLayerTree, and Rasterize events. Measure their durations. A Paint event consuming more than 4ms per frame leaves less than 12ms for everything else.

{
  "name": "Paint",
  "cat": "devtools.timeline",
  "ts": 142857000,
  "dur": 8400,
  "args": {
    "data": {
      "layerId": 42,
      "clipRect": [0, 0, 1920, 1080],
      "reason": "style change"
    }
  }
}

A clipRect matching the full viewport ([0, 0, 1920, 1080]) with an 8.4ms duration means half the frame budget was consumed rasterizing pixels that did not need to change. The target is a small clipRect and a duration below 4ms.

Common causes of over-broad invalidation:

  • Unbounded CSS properties like box-shadow, filter, and outline expand the paint area beyond the element’s border box.
  • Dense z-index stacking causes overlapping elements to be repainted together when any one of them changes.
  • Ancestor overflow — a mutation to a deeply nested element can expand up to the nearest overflow: hidden or overflow: scroll ancestor’s paint region.
  • Non-isolated backdrop-filter paints the entire backdrop area, not just the element itself.

The tell-tale in a trace is a Paint event whose duration scales with painted area rather than element count: a tight clipRect rasterizes in roughly a millisecond, while a full-viewport clipRect overruns the 4ms paint target on a single mutation.

Paint cost scales with clipRect area A tight clipRect paints in 1.2ms while a full-viewport clipRect paints in 8.4ms and overruns the 4ms paint target. Paint cost scales with clipRect area 4ms paint target clipRect 120×80 1.2ms clipRect 1920×1080 8.4ms

Region Isolation

CSS containment

.interactive-widget {
  contain: layout style paint; /* paint invalidation scoped to this element */
}

contain: paint is the key value for paint isolation. It clips the element’s paint region to its border box and prevents invalidation from propagating to ancestors. The browser can repaint only the widget without touching anything else. See CSS Containment Strategies for the full set of contain values and their layout-vs-paint trade-offs.

Containment stops invalidation propagating to ancestors Without containment a child mutation propagates up through every ancestor layer; with contain paint the dirty rect is clipped to the widget border box. Without containment root layer widget mutated child every ancestor repaints With contain: paint root layer (clean) widget · contain: paint mutated child clipped at the border box

Compositor promotion for stable elements

.interactive-widget {
  contain: layout style paint;
  transform: translate3d(0, 0, 0); /* promotes to own compositor layer */
  will-change: transform, opacity;  /* pre-rasterizes the element */
}

Once on its own compositor layer, mutations to transform and opacity bypass paint entirely — the GPU reuses the existing texture and only updates the transform matrix. Mutations to other properties still require rasterization, but only within this layer’s bounds. Because the layer edge is a hard ceiling on how far a dirty rect can spread, choosing where those edges fall is its own optimization — covered in Minimizing Paint Areas with Layer Boundaries.

// Lifecycle-aware promotion: hold the hint during interaction, release after
function startAnimation(widget) {
  widget.classList.add('animating') // CSS sets will-change: transform, opacity
}

function cleanupAfterTransition(widget) {
  widget.style.willChange = 'auto' // release GPU texture after animation
  widget.classList.remove('animating')
}

widget.addEventListener('transitionend', () => cleanupAfterTransition(widget), { once: true })

Leaving will-change active indefinitely holds the GPU texture allocation and prevents the tile cache from reclaiming memory. Always clean up after the animation completes.

Validation

After applying isolation:

  • Re-enable Paint Flashing. Green overlays should cover only the element that changed, not its neighbours or the whole viewport.
  • Check clipRect in the Paint trace event — it should match the element’s bounds, not the viewport.
  • Paint event duration should stay below 4ms per frame.
Metric Target
Paint event duration < 4ms per frame
Paint region (dirty rect) < 10% of viewport area for typical interactions
CompositeLayerCount (Layers panel) Stable under load; no runaway growth
CLS < 0.1

Track these in CI using Lighthouse (TBT is affected by excessive paint cost) and RUM longtask monitoring; Rendering Performance Metrics and Tooling covers wiring those observers and budgets. When paint flashing shows an unexpected full-viewport repaint in production, the traces above will show which element caused the invalidation cascade — and if a synchronous read provoked the repaint mid-task, Forced Synchronous Layouts explains the flush.

Treat these checks as a gate that each isolated interaction must pass before it ships: scoped flashing, a clipRect matching the element, and a sub-4ms Paint.

Post-isolation validation gate Three sequential checks — scoped paint flashing, element-sized clipRect, and sub-4ms paint — must all pass before an interaction ships. Post-isolation validation gate Flashing scoped clipRect = element Paint < 4ms Ship

How Paint Invalidation Works

After layout produces geometry, the engine records a display list for each layer — an ordered set of drawing commands — and paint invalidation decides how much of that list must be re-recorded when something changes. A property that affects appearance but not geometry (color, background-color, box-shadow, outline) marks the changed element’s bounding box as an invalidation rect, and the engine re-records only the display items that intersect it. The cost of a repaint therefore scales with two things: the area of the invalidation rect and the complexity of the drawing commands inside it. A colour change on a small button repaints a small, simple rectangle; the same change on a full-bleed hero with a gradient and a wide shadow repaints a large, expensive one.

This is why some visual changes are far cheaper than they look. A one-pixel border colour change on a card is nearly free; a box-shadow blur radius animating from 0 to 40px repaints a large blurred region every frame, because a wide blur is one of the most expensive raster operations there is. The DevTools Paint flashing overlay makes the invalidated region visible as a green rectangle, and the single most useful diagnostic it provides is a green rectangle larger than the thing you changed — the signal that an oversized layer or an over-broad invalidation is repainting more than necessary. Narrowing that region, or moving the frequently-changing element to its own layer, is the fix, detailed in minimizing paint areas with layer boundaries.

Invalidation and Layer Boundaries Interact

The subtlety that turns paint from a local concern into a global one is that invalidation is per layer, not per element. If a repainting element shares a layer with a lot of static content, every tile of that layer intersecting the invalidation rect must be re-rasterized — so an unpromoted element forces its neighbours to pay for its repaint. A small animated badge sitting on the same layer as a large static hero can trigger re-raster of the hero’s tiles on every frame, even though the hero did not change. Promoting the badge to its own layer isolates the invalidation to that layer’s small texture, which is why paint optimisation and layer promotion are two halves of one technique: reduce what invalidates, then isolate what remains.

The corollary is that layer boundaries should follow the change frequency of content, not its visual grouping. Content that repaints often — a live counter, a progress bar, a hover-driven highlight — benefits from its own layer so its invalidation cannot spill onto static neighbours; content that never changes is cheapest left unpromoted so it costs no texture memory. Getting this mapping right is the difference between a repaint that touches a 100×40 texture and one that re-rasterizes a full-viewport tile grid. The Layers panel and Paint flashing together show you the current mapping, and mismatches between change frequency and layer boundaries are the most common paint regression on a complex page.

/* A frequently-repainting element isolated onto its own layer so its
   invalidation cannot force re-raster of the static content around it. */
.live-badge {
  will-change: transform; /* own layer while it animates */
  /* its repaints now touch only this small texture, not the hero behind it */
}

Diagnosing a Paint Bottleneck

The workflow for a paint problem is fixed and fast. Open the Rendering tab, enable Paint flashing, and reproduce the interaction: the green rectangles show exactly what repaints and how large the region is. If the region is far larger than the element you changed, inspect the Layers panel for an oversized layer or check whether an over-broad selector is invalidating more than intended. Next, record a Performance trace and look for Paint and Rasterize bars — long ones point at expensive drawing commands (wide blurs, large shadows, filters) inside the invalidation, which is the cue to simplify the paint (a cheaper shadow, a smaller blur) rather than just shrink the region. The concrete DevTools steps are in debugging paint flashing in DevTools.

The mindset that keeps paint cheap is to treat every per-frame visual change as a question of area times complexity, and to attack whichever factor dominates. Sometimes the win is isolating the change to a smaller layer; sometimes it is making the drawing itself cheaper; sometimes it is realising the change did not need to be per-frame at all and can be expressed as a compositor-only transform or opacity that skips paint entirely. That last option is the best one when it applies, which is why the paint discipline here always circles back to transform and opacity best practices — the cheapest repaint is the one you avoid by not repainting at all.

Paint Regressions That Recur

A handful of paint problems show up often enough to keep on a checklist. A wide box-shadow or filter: blur() animating its radius is the classic expensive repaint, because blur cost scales with radius and it re-rasterizes a large region every frame — animate a cheaper property or pre-render the blurred state. A :hover rule that changes background on a large container repaints the whole container on every pointer move across it; scoping the hover effect to a small child, or expressing it as an opacity overlay on its own layer, avoids the large invalidation. And a frequently-updating element left on a shared layer forces its static neighbours to re-raster; promoting just that element isolates the cost.

The through-line is that paint cost is rarely about the number of repaints and usually about their area and drawing complexity, so the fixes are always one of: shrink the region, simplify the drawing, or isolate the change to its own small layer. Building the Paint-flashing check into code review catches these before they ship, because each one is invisible in a screenshot and obvious the moment the green rectangles appear. On a mature page, a periodic sweep with the overlay enabled routinely finds an oversized invalidation that crept in with a feature and has been quietly taxing every interaction since. Because these regressions are invisible until profiled, treating the sweep as a scheduled maintenance task — not something done only when a page already feels slow — is what keeps paint cost from accumulating unnoticed across dozens of small feature additions.

Frequently Asked Questions

What is the difference between paint invalidation and layout invalidation?

Layout invalidation marks geometry as stale, so the browser must recompute positions and sizes; paint invalidation marks pixels as stale, so the browser must re-rasterize a region. A layout change usually forces a paint, but a paint can happen without layout — for example, changing background-color invalidates paint but not layout. See Reflow and Repaint Triggers for which property changes trigger which stage.

Does contain: paint stop all invalidation from reaching ancestors?

It stops paint invalidation from propagating out of the element’s border box, and it also establishes a new containing block and stacking context. It does not by itself move the element onto its own compositor layer — for GPU-isolated transform and opacity changes you still need a promotion hint like transform: translate3d(0,0,0) or will-change.

Why does box-shadow cause a larger dirty rect than the element's box?

A shadow, outline, or blur filter paints pixels outside the border box, so the invalidation rect the compositor computes is the element’s visual overflow rect, not its layout box. Any mutation that touches the shadowed element dirties that larger area. Containment or a dedicated layer caps how far that expanded rect can reach.

How do I confirm a repaint stayed within an element's bounds?

Enable Paint Flashing in the DevTools Rendering tab and confirm the green overlay covers only the changed element, then open the Paint trace event and check that its clipRect matches the element’s bounds rather than the viewport. Debugging Paint Flashing in DevTools walks through both signals frame by frame.