Paint Invalidation & Repaint Boundaries

Part of Performance Budgeting & GPU Architecture.

Compositor-only animation is the goal, but very few real pages are compositor-only all the time. Content loads, a badge count changes, a skeleton resolves into text — and each of those marks a region of the page as needing to be painted again. Paint invalidation is the browser’s bookkeeping for which region, and repaint boundaries are the tools that stop a small change from dirtying a large area.

This matters for motion because paint and composite share a frame. An animation that is perfectly compositor-safe still drops frames if something else on the page is repainting a full-viewport region every time a value updates. The animation is not the problem; the repaint it is competing with is. Containment, repaint boundaries and content-visibility are how you shrink that competition.

Concept: invalidation is a rectangle, not a node

When a property that affects painting changes, the browser does not repaint the element — it marks a rectangle dirty and repaints every display item that intersects it. That rectangle is the union of the element’s visual overflow before and after the change, expanded by anything the element’s effects reach: a box shadow, a filter, an outline.

Two consequences follow, and they explain most surprising paint costs.

First, a small element can dirty a large area. A 24 px badge with a 40 px blurred shadow invalidates a region roughly 104 px across. A text-shadow on a heading that spans the viewport invalidates the full width. The visible change is tiny; the repaint is not.

Second, invalidation propagates up until something stops it. Without containment, a dirty rectangle inside a card can force the browser to re-examine the card’s ancestors, and in the worst case repaint a tile that covers most of the viewport. Repaint boundaries — established by contain: paint, by a compositor layer, or by content-visibility — are the point at which the browser can say “nothing outside this box changed” and stop.

Repainted area from one badge update, by containment strategyThe visible change is 24 by 24 pixels in every case; the difference is how far the dirty rectangle is allowed to spread.Repainted area from one badge update, by containment strategyNo containment, blurred shadow186 k px squaredNo containment, flat badge42 k px squaredcontain: paint on the card9 k px squaredBadge on its own layer1 k px squared
The visible change is 24 by 24 pixels in every case; the difference is how far the dirty rectangle is allowed to spread.

Execution model: where paint sits in the frame

Paint runs after layout and before composite. Its job is to turn the layout tree into display lists, and then raster them into tiles for the layers the compositor will assemble. Two costs hide in that sentence.

The first is display-list generation, which is proportional to the number and complexity of display items in the dirty region. Text is expensive; gradients and shadows are expensive; solid rectangles are cheap. The second is rasterisation, proportional to the pixel area of the dirty region multiplied by the square of the device pixel ratio — the same arithmetic that governs GPU texture memory, because rastered tiles are what a texture holds.

A repaint boundary changes what falls inside the region. contain: paint tells the browser that no descendant paints outside the element’s box, which means a change inside can never dirty anything outside it — and, just as usefully, that content outside can be skipped when the element itself is dirty. contain: layout makes the same promise for geometry, so an internal size change cannot move a sibling. contain: strict is both plus size containment, which requires the element’s dimensions to be independent of its contents.

content-visibility: auto goes further: it skips rendering work entirely for a subtree that is outside the viewport, including layout and paint. For a long list this is the difference between painting a hundred rows and painting the eight that are visible. It implies contain: layout paint style on the skipped subtree, which is why it also acts as a repaint boundary once the content does come into view.

How a property change becomes a repaintA repaint boundary is the point where the dirty rectangle stops expanding upward.How a property change becomes a repaintPaint propertychangesDirty rectcomputedbefore + afterboundsExpanded byeffectsshadow, filter,outlineStops at theboundarycontain / layerDisplay listrebuiltTilesrasterisedarea x DPR squared
A repaint boundary is the point where the dirty rectangle stops expanding upward.

Property reference

Property / value What it promises Cost when used Notes
contain: paint Descendants never paint outside the box Establishes a stacking context and a clip The cheapest useful repaint boundary
contain: layout Internal geometry cannot move siblings Establishes a containing block Pair with paint for animated components
contain: size The box size does not depend on contents Requires explicit dimensions Rarely correct on text content
contain: strict Layout, paint, size and style together Strongest constraints on authoring Use only on genuinely self-contained widgets
contain: content layout paint style without size Safe default for cards and rows The usual choice for list items
content-visibility: auto Skip rendering while off-screen Needs contain-intrinsic-size to avoid scrollbar jumps Highest leverage for long lists
contain-intrinsic-size Placeholder dimensions while skipped None per frame Prevents scroll position jumping as rows resolve
will-change: transform Element will move; promote it One texture at element size x DPR squared A layer is also a repaint boundary — an expensive one
isolation: isolate New stacking context, no promotion Style only Boundary for blend modes, not for paint

Annotated examples

Containing a card so its internals cannot dirty the page

/* Intent: an activity card whose badge and timestamp update frequently.
   Containment keeps each update inside the card's own box. */
.activity-card {
  contain: content;              /* layout + paint + style, size stays automatic */
  content-visibility: auto;      /* skip rendering entirely while off-screen */
  contain-intrinsic-size: auto 148px;  /* remembered height, no scroll jump */
}

.activity-card__badge {
  /* A flat badge invalidates its own box; a blurred shadow would invalidate
     far more, so the emphasis comes from colour rather than from a shadow. */
  transition: background-color 140ms linear, transform 140ms ease-out;
}

.activity-card__badge.is-updated { transform: scale(1.08); }

@media (prefers-reduced-motion: reduce) {
  .activity-card__badge { transition: background-color 140ms linear; transform: none; }
}

Rendering Impact: paint, scoped. The badge’s colour change repaints a region bounded by the card rather than by the viewport; the scale is composite-only and does not repaint at all.

Replacing an animated shadow with a composited one

/* Intent: the hover lift needs a shadow that grows, but animating
   box-shadow repaints the whole expanded region every frame. */
.tile { position: relative; contain: paint; }

.tile::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 18px 40px rgb(30 27 75 / 28%);  /* painted once, at rest */
  opacity: 0;
  transition: opacity 180ms ease-out;
  pointer-events: none;
}

.tile:hover::after { opacity: 1; }  /* composite-only fade of a static shadow */

@media (prefers-reduced-motion: reduce) {
  .tile::after { transition-duration: 60ms; }
}

Rendering Impact: composite only during the interaction. The shadow is rasterised once when the pseudo-element is first painted; the hover animates opacity, which the compositor blends without touching paint.

Skipping work for rows nobody can see

/* Intent: a feed of several hundred rows, each with its own entry animation.
   Off-screen rows should cost nothing until they approach the viewport. */
.feed__row {
  content-visibility: auto;
  contain-intrinsic-size: auto 96px;
}

.feed__row.is-entering {
  animation: row-in 260ms cubic-bezier(0, 0, 0.2, 1) both;
}

@keyframes row-in {
  from { opacity: 0; translate: 0 8px; }
}

@media (prefers-reduced-motion: reduce) {
  .feed__row.is-entering { animation: none; opacity: 1; translate: 0 0; }
}

Rendering Impact: none for skipped rows — they are not laid out, painted or composited. Rows entering the viewport pay one layout and one paint, then animate on the compositor.

DevTools workflow

  1. Enable Rendering → Paint flashing and interact with the page. Green rectangles are repainted regions. The size of the rectangle, not its colour, is the finding: a small change producing a large flash is an invalidation-scope problem.
  2. Record a Performance trace and select a Paint event. The summary panel names the layer and the dirty rectangle’s dimensions. Multiply width by height by the square of the device pixel ratio for the true rasterisation cost.
  3. Open the Layers panel to see where the existing repaint boundaries are. Every compositor layer is a boundary, which is why an accidental promotion sometimes improves paint cost — and why removing one can make paint worse until containment replaces it.
  4. Add contain: content to the suspect component and re-run paint flashing. The flash should shrink to the component’s box. If it does not, something inside is escaping the box — usually an absolutely positioned child or an overflowing shadow.
  5. For content-visibility, watch the Rendering → Scrolling performance issues overlay and the frame timeline while scrolling fast. Rows resolving late show up as brief blank areas, which is the signal to increase contain-intrinsic-size accuracy.

Failure modes and fixes

Paint flashing lights up the whole viewport for a one-line text change. Something between the text and the root is not a boundary, and an ancestor’s effect is expanding the rectangle. Add contain: content to the nearest sensible component box, and check for a full-width text-shadow or filter on an ancestor.

contain: paint clips content that should overflow. That is the promise being enforced. A dropdown that escapes its card cannot be inside a paint-contained box; move the containment to a child, or render the overflowing part in a portal or the top layer.

content-visibility: auto makes the scrollbar jump. The skipped subtree reports the size given by contain-intrinsic-size, and if that is far from the real height the scroll position shifts when it resolves. Use the auto <length> form so the browser remembers the last measured size, and set the length to a realistic average.

Find-in-page stops matching content. Content skipped by content-visibility: auto is still searchable — the browser renders it on demand — but content hidden with content-visibility: hidden is not. The two are frequently confused; only auto is appropriate for real content.

Promoting an element fixed paint but memory climbed. A layer is a repaint boundary, but it is the most expensive kind: it holds a texture for the element’s full area. Prefer containment first and promotion only for elements that genuinely animate, as covered in layer promotion and will-change strategy.

Choosing a boundary for the problem you haveReach for the cheapest tool that makes the promise you need.Choosing a boundary for the problem you havecontain: contentcontent-visibility:autoCompositor layerFrequent internalrepaintsBestWorksOverkillHundreds ofoff-screen itemsPartialBestMemory blowoutElement thatactually animatesHelps neighboursNot applicableCorrectContent mustoverflow the boxClips itClips itAllowedCost when idleNoneNoneOne texture
Reach for the cheapest tool that makes the promise you need.

Accessibility and reduced-motion notes

Containment and skipped rendering interact with assistive technology in ways worth checking rather than assuming.

content-visibility: auto keeps its subtree in the accessibility tree and in find-in-page, and the browser renders it on demand when focus or a search lands inside. That makes it safe for real content — but only in the auto form. Using hidden for content that a user might need to reach removes it from both, which is a genuine accessibility regression rather than an optimisation.

Reduced motion has a quieter interaction here. When entry animations are disabled, rows appear the instant they resolve, which can make a long list feel like it is flickering into existence during a fast scroll. The fix is not to reinstate the animation but to make the placeholder honest: an accurate contain-intrinsic-size means the row occupies its final space before it paints, so the resolution is a fill rather than a jump. This is the same principle behind avoiding layout shift, discussed in auditing layout shifts during CSS transitions.

Frequently asked questions

Does contain: paint promote the element to its own layer?

No. Containment is a paint-scoping promise, not a promotion: it establishes a stacking context and a clip, but allocates no GPU texture. That is exactly why it is the cheaper tool — you get a repaint boundary without the memory cost of a layer.

Is content-visibility: auto safe for content that must be findable?

Yes, in the auto form. Skipped subtrees remain in the accessibility tree and in find-in-page, and the browser renders them on demand when focus or a match lands inside. content-visibility: hidden is the unsafe one — it removes the content from both.

Why did adding contain: content break my dropdown?

Paint containment clips descendants to the container’s box, so anything designed to overflow — a dropdown, a tooltip, a focus ring drawn outside the border — is cut off. Either move the containment to a child that has no overflowing content, or render the overflowing part in the top layer.

How large is too large for a repainted region?

Treat the rasterisation cost as area multiplied by the square of the device pixel ratio. A 400 by 300 region on a DPR 3 phone is the same work as a roughly 1200 by 900 region at DPR 1. If a repaint of that size happens on every frame of an interaction, it will not fit inside the frame budget alongside anything else.

Should I use contain on every component?

No. Containment is a promise the browser enforces, and enforcing a promise the component cannot keep produces clipped or mis-sized content. Apply it where a component repaints often or contains many items, and verify with paint flashing that the boundary is doing something.