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.
Identifying Over-Broad Invalidation
DevTools workflow:
- Open the Rendering tab (Esc → Rendering in DevTools).
- Enable Paint Flashing. Green overlays appear over any region being repainted.
- Trigger the interaction. Full-viewport green flashes indicate uncontained invalidation.
- 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.
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, andoutlineexpand the paint area beyond the element’s border box. - Dense
z-indexstacking 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: hiddenoroverflow: scrollancestor’s paint region. - Non-isolated
backdrop-filterpaints the entire backdrop area, not just the element itself.
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.
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.
// 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
clipRectin thePainttrace event — it should match the element’s bounds, not the viewport. Paintevent 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. When paint flashing shows an unexpected full-viewport repaint in production, the traces above will show which element caused the invalidation cascade.