Skip to content

The mystery of the self-repainting #document: Paint flashing and composited layers

Published:

In a recent audit I opened a product page, didn’t touch anything and left the mouse still. The page was at rest: no scroll, no clicks, no network requests. And yet the laptop fan started to spin up. I opened the Layers tab in Chrome DevTools and there was the clue: the #document layer showed a sky-high Paint count, and every time I checked again it had climbed even higher, with the page sitting still.

When a page at rest keeps repainting, it’s burning battery and CPU to show nothing new. On a laptop you hear it in the fan; on a phone you feel it in the battery. The culprit is almost always a small dynamic component, a counter, a quotes ticker, a text carousel, that animates well but paints badly.

The symptom: Paint count spiking at rest

The warning sign is simple: a still page shouldn’t repaint anything. If you stop interacting and the browser keeps working, there’s an animation running underneath, invalidating pixels every frame.

What’s interesting about this case is that the developer had done their homework. The ticker’s animation used transform, one of the properties everyone recommends because it “triggers neither layout nor paint”. And yet the #document kept repainting nonstop. The theory said one thing and the Paint count said the opposite.

Diagnostic tools

To hunt down this kind of problem there are two Chrome DevTools features that work together.

Paint flashing (Rendering panel)

It paints green over every region of the screen the moment the browser repaints it. To enable it:

  1. Open the command menu with Cmd/Ctrl + Shift + P
  2. Type “Show Rendering” and press Enter
  3. Tick the Paint flashing checkbox

Now leave the page at rest and watch. If you see constant green flashes over the ticker (or worse, over a huge area of the page), the browser is repainting every frame. How far the green spreads tells you how much surface is invalidated: often it’s not just the component, it’s the whole layer that contains it.

Chrome DevTools Rendering panel with the Paint flashing checkbox enabled; the .ticker__track is highlighted green because it repaints every frame, while the #document repaint counter keeps climbing.
With Paint flashing enabled, the browser tints the ticker area green every frame: the real-time signal that something is repainting while the page is at rest. Open larger image

Layers tab

The Layers tab shows the page’s compositing layer tree. To open it, command menu → “Show Layers”. For each layer you care about:

Select the #document layer with the page at rest and look at its Paint count. Here it helps to know a quirk of the tool: the Layers panel’s Paint count doesn’t update live. It’s a snapshot that only refreshes when you reselect the node, so you won’t see it climb on its own while it stays selected.

To confirm it’s growing, select another layer and go back to #document every few seconds: if the number is higher each time with the page still, you’ve confirmed the ticker has no layer of its own and is dragging the whole document layer into repainting with it. For the real-time signal, lean on Paint flashing; the Layers panel’s Paint count is the cumulative numeric confirmation.

Chrome DevTools Layers tab with the #document layer selected: it shows a high, growing Paint count and, under Compositing reasons, that it's the document.rootScroller with accelerated scrolling.
In the Layers tab, the #document layer accumulates a high, growing Paint count because the ticker shares its layer. The number is a snapshot: reselect the node to watch it climb. Open larger image

The scenario: a ticker that animates well and paints badly

This is the kind of component that shows up again and again: a quotes ticker that scrolls its values in a loop. The HTML and CSS are spotless.

<div class="ticker">
  <div class="ticker__track">
    <span>EUR/USD 1.0921 ▲</span>
    <span>GBP/USD 1.2734 ▼</span>
    <span>USD/JPY 149.82 ▲</span>
    <!-- …the set of values is duplicated for a seamless loop… -->
  </div>
</div>
.ticker {
  overflow: hidden;
}

.ticker__track {
  display: inline-flex;
  gap: 2rem;
  white-space: nowrap;
  /* We only move transform: the compositor's "cheap" property */
}

And the scrolling logic, in JavaScript with requestAnimationFrame to move the track frame by frame:

// The track scrolls by mutating transform every frame.
// Since it's a JS-driven animation, Chrome won't promote it on its own.
const track = document.querySelector(".ticker__track");
let x = 0;

function tick() {
  x -= 0.6;
  if (-x >= track.scrollWidth / 2) x += track.scrollWidth / 2;
  track.style.transform = `translateX(${x}px)`;
  requestAnimationFrame(tick);
}

requestAnimationFrame(tick);

On paper it’s a textbook example: we only move transform, we don’t touch properties that trigger layout. And yet Paint flashing paints the ticker’s area green every frame, and the #document repaints nonstop while the ticker scrolls.

The problem isn’t which properties we animate, but where the moving pixels live.

The explanation: two threads, one layer

To understand why transform isn’t enough, we have to separate two things that happen on different threads.

The promise of transform and opacity is this: if a layer is already rasterized, the compositor can move or fade it on its own, without bothering the main thread and without repainting. Free animation on the GPU.

But that promise comes with a condition almost nobody mentions: the element that moves has to be on its own composited layer. If the ticker shares a layer with the #document, the compositor can’t move it in isolation, because moving that piece would leave a hole in the bitmap of the whole page. The browser’s only way out is to re-rasterize the entire document layer on every frame of the animation.

That’s the trap: using transform doesn’t give you compositing by magic. It gives you compositing only if the element already has its own layer. Without promotion, transform rasterizes like anything else, and drags the whole layer that contains it.

An honest caveat: a declarative animation with @keyframes of transform/opacity usually does get promoted automatically, Chrome detects the animation and gives it its own layer. The case that repaints is precisely this one: an animation driven by JavaScript (changing inline styles frame by frame, or kicking off transitions from code). The browser doesn’t know in advance that it’s a continuous animation, so it won’t promote the element on its own. That’s why real-time data tickers are repeat offenders for this problem.

The fix: promote the element to its own layer

The fix is to tell the browser explicitly that this element is going to change its transform, so it reserves a composited layer before the movement starts.

.ticker__track {
  display: inline-flex;
  gap: 2rem;

  /* Promote the element to its own composited layer */
  will-change: transform;
}

Flip the toggle in the demo to see the change live: the #document’s repaint counter freezes and the ticker gets its own composited layer.

Leave the demo at rest and watch the #document's repaint counter climb on its own. Turn on will-change to promote the ticker to its own layer and watch it stop. Open the demo in a new tab ↗ to inspect it with your own DevTools (Layers and Rendering tabs), or open it already fixed with ?promoted to confirm the #document doesn't repaint from the first render.

You can also check it in the Layers tab:

The classic alternative, predating will-change, is to force the layer with a 3D transform:

.ticker__track {
  transform: translateZ(0); /* the old GPU-promotion "hack" */
}

It works because any 3D transform forces the browser to create a layer. But will-change is the right tool today: it expresses intent (“this is going to change”) instead of a side effect, and the browser can manage the layer more intelligently, creating it just before the animation and releasing it afterwards.

The golden rule: promote with care

At this point the temptation is to slap will-change: transform on everything that moves. Bad idea. Every composited layer consumes GPU memory, and over-promoting has its own cost:

The criteria I apply in audits:

  1. Measure first. If the Layers tab and Paint flashing show no repaints at rest, there’s nothing to promote. Don’t optimize blindly.
  2. Promote only what animates continuously or is driven by JS: tickers, carousels, live counters, complex sticky elements.
  3. Put will-change on the specific element that moves, not on its container or half the tree.
  4. If the animation is occasional, add will-change just before (on hover, on start) and remove it when it’s done, so you don’t keep the layer reserved permanently.

A single well-placed layer fixes a #document that repaints on its own. Ten badly-placed layers create a new memory problem. Performance integrity isn’t about promoting everything, it’s about promoting just enough, where the measurement asks for it.

Conclusion

transform and opacity are the right properties to animate, but they’re not magic: they only travel through the compositor if the element has its own composited layer. When a dynamic component, almost always driven by JavaScript, animates without its own layer, it drags the whole #document layer into repainting every frame, and you see it in the Paint count and in Paint flashing’s green even with the page at rest. The fix is to promote that specific element with will-change for the property it animates, verify in the Layers tab that the #document stops repainting, and resist the temptation to promote everything.

References


Next Post
Antigravity Skills: advanced YAML frontmatter configuration