Debugging Paint Flashing in DevTools
A single-pixel counter update that flashes the entire viewport green means the paint phase is re-rasterizing thousands of clean pixels every frame — an invalidation-region bug, not a layout bug. This guide is a hands-on walkthrough of the Chrome DevTools Rendering → Paint flashing overlay, part of Paint Invalidation and Regions within the broader Layout and Paint Optimization work. Where Finding Layout Thrashing in DevTools attributes a forced reflow to a JavaScript line, paint flashing attributes an oversized repaint to the CSS property that expanded its dirty rect.
Reproducing an Oversized Paint
Paint flashing is only useful when you can make the bug happen on demand. The snippet below animates a live-region badge once a second. Because the badge sits inside a card that carries a soft box-shadow, the invalidation does not stop at the badge — it expands to the shadow’s blur radius and repaints the whole card on every tick.
<div class="card">
<p>Requests in flight</p>
<span id="badge" class="badge">0</span>
</div>
<style>
.card {
/* box-shadow makes the paint rect bleed past the border box */
box-shadow: 0 8px 40px rgba(35, 40, 58, 0.25);
padding: 24px;
border-radius: 12px;
}
.badge {
font-variant-numeric: tabular-nums;
}
</style>
<script>
let n = 0;
setInterval(() => {
// one-character text change — should dirty ~20px, dirties the whole card
badge.textContent = String(++n);
}, 1000);
</script>
The text mutation is tiny. What makes it expensive is that the changed element is a paint descendant of a box whose visual bounds exceed its layout bounds. When the browser computes the dirty rectangle, it must include every pixel the change could affect — and the box-shadow says the card’s paint reaches 40px of blur beyond its border. The diagram below shows how the reported paint region grows relative to the actual DOM mutation.
How Paint Flashing Decides What Turns Green
The green overlay is not a JavaScript instrumentation layer — it is drawn by the compositor thread in the GPU process. When the main thread finishes the paint phase, it hands the compositor a list of dirty rectangles and their layers. Before rasterizing each dirty tile, the compositor blends a translucent green quad over exactly the invalidated region, so the overlay traces the raster work, not the DOM change. That distinction is why a one-character edit can light up a whole card: you are watching the rasterizer’s task list, not your mutation.
Internally the invalidation lives in the layer’s paint-property tree. Each PaintLayer accumulates an invalidation rect during the main thread’s paint step; that rect is unioned with the element’s visual overflow — the border box expanded by shadows, outlines, filters, and overflow: visible descendants. The compositor then intersects the union with the layer bounds and schedules the intersected tiles for re-rasterization. Nothing here consults the number of DOM nodes that changed; the cost is proportional to dirty pixel area, which is why the same overlay that owns Reflow and Repaint Triggers is the fastest way to see area-scaled cost.
To enable the overlay: open DevTools, press Esc to reveal the drawer, choose the Rendering tab, and tick Paint flashing. Trigger the interaction and watch which regions blink. Then record a Performance profile over the same interaction and read the paint region straight out of the trace. The annotated tree below is what the reproduction above produces — a Paint event whose clipRect covers the card-plus-shadow, not the badge.
Frame @ 16.6ms budget
└─ Task (main thread) 1.9 ms
├─ Recalculate Style 0.2 ms 1 element (#badge textContent)
├─ Layout 0.0 ms ── skipped, geometry unchanged
├─ Pre-Paint 0.1 ms
│ └─ PaintInvalidationTracking
│ ├─ target : span#badge rect [612,214 24x18] ← the change
│ └─ expandedTo: div.card + shadow rect [500,150 260x140] ← what flashes
└─ Paint 1.4 ms
└─ clipRect [500,150 260x140] reason: "style" layerId: 12
(green overlay = this clipRect, 36400 px repainted for a 432 px change)
The two lines to read are target and expandedTo. The change touched 432 px; the browser repainted 36,400 px. The ratio — not the absolute duration — is the invalidation bug. On a fast desktop the 1.4ms Paint hides inside the budget; on a mid-tier phone the same region takes 9–11ms and drops the frame.
Tracing the Flash Back to Its Trigger and Fixing It
Once the overlay shows an oversized flash, the attribution question is: which property expanded the rect? Toggle candidate properties off one at a time in the Elements → Styles pane and watch the green region shrink. In the reproduction, disabling box-shadow collapses the flash to the badge immediately — that is your culprit. The fix is not to delete the shadow but to stop the frequently-changing element from sharing a paint region with it. contain: paint clips the badge’s invalidation to its own border box so it can never expand into the card’s shadow, and promoting the badge to its own compositor layer means the repaint is a tile the GPU already owns. The decision tree below routes each observed flash to the property that widened it.
Here is the complete before/after. The before is the reproduction; the after isolates the badge’s paint so the flash shrinks to 432 px and the Paint event’s clipRect matches the badge’s bounds:
<!-- BEFORE: badge shares a paint region with the card's shadow -->
<style>
.card { box-shadow: 0 8px 40px rgba(35, 40, 58, 0.25); padding: 24px; }
.badge { font-variant-numeric: tabular-nums; }
</style>
<!-- AFTER: badge's invalidation is clipped to its own box -->
<style>
.card { box-shadow: 0 8px 40px rgba(35, 40, 58, 0.25); padding: 24px; }
.badge {
font-variant-numeric: tabular-nums;
contain: paint; /* clips dirty rect to the badge's border box */
transform: translateZ(0); /* promotes the badge to its own compositor layer */
}
</style>
<script>
let n = 0;
setInterval(() => {
// now dirties only the badge's ~432 px, never the card's shadow
badge.textContent = String(++n);
}, 1000);
</script>
contain: paint is the load-bearing change: it tells the engine the badge’s paint output cannot escape its border box, so the invalidation union never reaches the shadow. The transform: translateZ(0) is optional reinforcement — it hands the badge a dedicated layer so its repaints raster into a tile the compositor holds separately, the same promotion mechanism documented in Transform and Opacity Best Practices. Reach for the layer hint only when the element updates many times per second; for the once-a-second badge, contain: paint alone is enough and avoids the memory cost that When to Use will-change Without Memory Leaks warns about. For elements that scroll or animate off-screen, skipping their paint entirely with Content Visibility and Rendering Subtrees beats containing the paint at all.
Verification Checklist
Frequently Asked Questions
Why does paint flashing light up an element I never touched?
Because the green overlay tracks rasterization, not DOM mutation. If your changed element shares a compositor layer or paint region with the highlighted one — through an ancestor box-shadow, an overflow clip, or overlapping z-index stacking — the invalidation rect unions the two and both re-rasterize together. Toggle candidate properties off in the Styles pane and watch the green region shrink to find which one widened the rect.
Is the green overlay from paint flashing itself slowing down my page?
The overlay adds a translucent blend pass on the compositor thread, so it does cost a little, but it is drawn only over regions that were already going to be rasterized. It will not turn a clean region green, so it cannot invent invalidation that is not there. For precise timing numbers, read the Paint event duration from a Performance trace with the overlay off rather than trusting the visual flash rate.
Does contain: paint stop the flash without a new compositor layer?
Yes. contain: paint clips the element’s paint output to its border box and blocks invalidation from propagating to ancestors, all on the existing layer — no new texture is allocated. Adding transform: translateZ(0) on top promotes the element to its own layer, which further isolates repaints but costs GPU memory. Use containment first and add the layer only for high-frequency updates.
Why is the flash tiny on my laptop but the page still janks on a phone?
Paint cost scales with dirty pixel area times fill rate, and a phone’s GPU has a fraction of the fill rate and far less memory bandwidth. The same 36,400-pixel repaint that takes 1.4ms on desktop can take 9–11ms on a mid-tier phone, which blows the frame budget. Always profile with CPU throttling enabled, and judge the fix by the pixel-area ratio, not the desktop millisecond count.
Related Guides
- Paint Invalidation and Regions — the parent guide on how dirty rectangles are marked, expanded, and isolated.
- Reflow and Repaint Triggers — which CSS property changes force a repaint versus a full relayout.
- Finding Layout Thrashing in DevTools — the sibling workflow for attributing forced synchronous layouts in the Performance panel.
- Transform and Opacity Best Practices — why promoting to a compositor layer moves repaints off the main thread.