PaintENGINE-SPECIFICDEVICE-SPECIFICSIMPLIFIED

Layer Explosion

Promoting everything with will-change trades main-thread paint for GPU memory and per-frame compositing — and past a small number of layers that trade inverts.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

If layers make animation cheap, why does adding will-change to more elements make the page slower?

The user intent

A team read that compositing is fast, added will-change: transform to the card component, and shipped. The list has two hundred cards. Nothing animates until hover.

The obvious build

will-change tells the browser to optimise ahead of time. More of it means more things are optimised, so putting it on any element that might move is free insurance.

Why it breaks

Each promoted element gets its own bitmap, sized to its area in *device* pixels. Two hundred cards at 320x180 CSS pixels on a 3x display is roughly 400MB of texture memory at four bytes per pixel — an allocation no phone will grant, and one the browser will start refusing or evicting.

How it breaks in a real browser
  • Each promoted element gets its own bitmap, sized to its area in *device* pixels. Two hundred cards at 320x180 CSS pixels on a 3x display is roughly 400MB of texture memory at four bytes per pixel — an allocation no phone will grant, and one the browser will start refusing or evicting.
  • Compositing is per-frame work proportional to layer count and drawn area. A frame that had to draw one surface now draws two hundred, and the compositor starts missing the deadline on the thread that was supposed to be safe (The Frame Budget).
  • Compositing *assignment* is itself main-thread work that runs on style updates. More promoted elements means a more expensive pass every time styles change.
  • Promotion begets promotion: anything overlapping a composited element may need its own layer to preserve paint order, so the count grows faster than the number of declarations you wrote (Compositing Layers).
  • When the memory budget is exceeded, engines evict tiles or decline promotion. The symptom is blank regions during scroll and animations that suddenly run on the main thread — the exact failure the promotion was supposed to prevent.
  • Text rasterised into a promoted layer can lose subpixel antialiasing, so a blanket will-change degrades legibility across the whole interface as a side effect.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • will-change is a hint, not an instruction. It tells the engine that a property is expected to change so it can prepare — usually by promoting the element to its own compositing layer, sometimes by creating a stacking context, and sometimes by doing nothing at all.
  • A layer costs memory equal to width x height x device-pixel-ratio squared x bytes per pixel, held for as long as the layer exists. This is the term people omit, and it is the term that dominates.
  • It also costs per-frame compositor work: one more surface to transform, blend and draw, plus the overdraw where it overlaps others.
  • The hint has no natural end. A layer created by an animation is released when the animation finishes; a layer created by a declaration in a stylesheet exists for as long as the declaration matches, which is usually the whole session.
  • will-change also creates a stacking context and containing block for fixed-position descendants, which can change layout and paint order in ways that have nothing to do with performance and everything to do with a broken tooltip.
  • Engines defend themselves. There are caps on total layer memory and heuristics that ignore hints applied too broadly, which means an over-promoted page does not get faster *or* fail loudly — it gets slower in a way that looks like nothing in particular.

What this makes the browser do

And which of it is avoidable.

  • A compositing assignment pass on the main thread that considers every candidate element and every overlap relationship.
  • Allocating and rasterising a bitmap per promoted element, tiled for large ones, then uploading textures to the GPU (The Transfer You Forgot to Count).
  • Holding all of those textures in GPU and system memory for the layer's lifetime, contending with images, video, canvases and every other tab.
  • Per frame: iterating the layer tree, applying transforms, blending in order, including regions where several layers overlap and the lower ones are drawn only to be covered.
  • Under pressure: evicting tiles, re-rasterising evicted tiles when they come back into view, and declining further promotions.
  • Avoidable: essentially all of it, for any element that is not currently animating.

The arithmetic nobody does

SIMPLIFIEDFour bytes per pixel and full coverage is the teaching model; a real engine may tile, may compress, and may allocate only the visible portion of a large layer, so the absolute numbers run high. What transfers exactly is the scaling: memory grows with area and with the square of the device pixel ratio, which is why the phone is the device that breaks.

The cost of a layer is not abstract. It is width times height times device pixel ratio squared times bytes per pixel, and that memory is held for as long as the layer exists. Doing the multiplication once, for the actual component and the actual devices you ship to, ends most arguments about will-change immediately.

The table below is the same card at three sizes and three densities, at four bytes per pixel. Read the last column as "held for the whole session, per instance".

  • Multiply the card row by the number of instances rendered. Two hundred cards at 3x is not a rounding error; it is more texture memory than the device will give the whole tab.
  • Overlap-forced promotions are not in this table and are not in your stylesheet either — count them in the layers panel.
  • The memory is held whether or not anything is animating. A hint in a base class is a permanent allocation.
  • Engines tile large layers and can allocate less than full coverage, so treat these as an upper bound with the right scaling behaviour rather than as exact figures.
Promoted elementCSS sizeDensityDevice pixelsApprox. memory per instance
List card320 x 1801x57,600~0.23 MB
List card320 x 1802x230,400~0.92 MB
List card320 x 1803x518,400~2.07 MB
Full-width row1280 x 962x491,520~1.97 MB
Modal overlay1280 x 8002x4,096,000~16.4 MB
Phone viewport overlay390 x 8443x2,962,440~11.8 MB

What promotion actually buys, per element

Promotion is a trade, and the trade is only good when the thing you are avoiding is expensive and recurring. An element that animates transform continuously during an interaction is a good candidate. An element that never animates, or whose content is repainted every frame anyway, is not — you pay the memory and keep the paint.

The same list, promoted three ways
ChangestylelayoutpaintcompositeWhy
No promotion; one card animates transformyesnomaybeyesThe engine promotes the animating card for the animation's duration and demotes it afterwards. One temporary layer, no permanent cost.
`will-change: transform` on the animating card only, scoped to the interactionyesnonoyesOne layer for a fraction of a second, and no blank first frame. This is the version the advice is actually about.
`will-change: transform` in the card's base class, 200 instancesyesnonoyesTwo hundred permanent bitmaps. The animation is delegated and everything else — scroll, memory, compositing per frame — is worse.
`will-change: transform` on the scroll container insteadyesnomaybeyesOne enormous layer covering the whole list, tiled. Cheaper than 200 layers, still a large allocation, and it does not help the per-card animation at all.
Containment on each card, no promotionyesnoyesyesInvalidation is isolated without allocating a surface. Paint still runs, but only for the card that changed (CSS Containment).
`content-visibility: auto` on off-screen sectionsmaybenonoyesOff-screen content is skipped entirely — no style, no layout, no paint until it approaches the viewport (content-visibility).

caveat The maybe values depend on what else overlaps the element and on the engine's current triggers. Verify the layer count in the layers panel rather than reasoning from this table alone — that is the whole point of the lesson (Compositing Layers).

Deciding whether to promote this element

The decision is not "should I use will-change" but "what is expensive about this element, how often, and how many of it are there". Those three answers pick the option for you, and for most elements the answer is the first one.

Should this element get its own layer?

What is the recurring cost you are trying to remove, and how many instances exist?

Do not promote

when The element does not animate, or animates once and briefly, or there are many instances of it.

cost A possible extra paint per animation frame on the main thread — which on a quiet page is genuinely fine.

Let the engine promote for the animation

when A declarative transform/opacity transition or animation, on a bounded number of elements.

cost A possible blank or stale first frame if raster is late; no permanent memory (Cheap and Expensive Animation).

Scoped `will-change`, added and removed around the interaction

when The first frame matters — a drawer, a menu, a drag — and there is one of them at a time.

cost Cleanup code, including the interrupted path, plus a layer for the duration.

Containment instead

when The problem is repaint scope, not per-frame animation cost.

cost A promise about the subtree that must actually hold, and clipping bugs when it does not (CSS Containment).

Render fewer elements

when The list is long and the per-element cost is the whole problem.

cost Virtualisation complexity, and real losses in find-in-page, anchors and accessibility-tree completeness (List Virtualization).

The declaration that scales badly, and the one that does not
1/* Cost scales with instances rendered, not with lines of CSS. */
2.card {
3 will-change: transform; /* one bitmap per card, for the whole session */
4}
5
6/* Scoped to the moment the hint is useful. */
7.card { transition: transform 180ms ease-out; }
8.card[data-lifting] { will-change: transform; }
9
10@media (prefers-reduced-motion: reduce) {
11 .card { transition-duration: 1ms; }
12}

The two blocks are visually identical in every frame the user sees. The difference is a number in the layers panel and a figure in the GPU memory column — neither of which appears anywhere in the stylesheet, which is exactly why this mistake is so easy to ship (Measure Before Optimising).

How to build it

Most important first.

  • Promote a small, bounded number of elements — the ones actually animating right now. "A handful" is the right order of magnitude; "every card in a list" never is.
  • Scope the hint to the interaction. Add it on pointer-enter or focus, remove it when the animation ends, with a timeout fallback for the interrupted case (Compositing Layers).
  • Prefer letting the engine promote for you. A declarative transform/opacity transition is promoted for its duration and demoted afterwards, which is the lifecycle you would have to write by hand.
  • Never put will-change in a component's base styles. A component is instantiated an unknown number of times, and the cost scales with instances, not with declarations.
  • Keep promoted surfaces small. Promote the moving element, not its full-viewport container.
  • Count layers before and after any change that touches compositing. The layers panel gives an exact number and a memory figure; intuition does not.
  • If the real problem is repaint cost, consider containment first — it isolates invalidation without allocating a surface (CSS Containment).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Reduced text sharpness in promoted layers affects users with low vision first. A change made for frame rate that degrades legibility for that group is a net accessibility regression, and it is easy to miss because it is subtle and everywhere at once.
  • Memory pressure ends sessions. A tab discard on a low-end device loses form state and scroll position, which costs far more to a user navigating by keyboard or switch device than to a mouse user who can re-find their place quickly (Form State Is a Draft).
  • Layer explosion frequently arrives alongside animation-heavy design. Whatever the layer count, prefers-reduced-motion still governs whether the motion happens at all — and the animations you skip for that user are also layers you never allocate (Contrast, Colour and Motion).
  • A stacking context created by will-change can trap a modal, a tooltip or a focus ring inside a clipping ancestor, so a purely performance-motivated declaration can break focus visibility and dialog rendering (Focus Management).
  • Compositor-driven animation continuing during a main-thread freeze applies here too: a page with hundreds of promoted layers can look busy and alive while nothing at all is progressing, with no announcement to assistive technology.

What can go wrong

Failure modes
  • Memory exhaustion on low-end devices: tab discards, renderer crashes, or the browser silently refusing promotions and falling back to main-thread paint.
  • Compositing itself becoming the bottleneck — frames late with an idle main thread, which is the hardest variant to diagnose because every main-thread measurement looks healthy.
  • Blank or checkerboarded regions during scroll as tiles are evicted and re-rasterised (Scroll and Input Latency).
  • Layout and stacking bugs from the stacking context will-change creates: a position: fixed child that is now positioned relative to the promoted ancestor, or a tooltip that can no longer escape its parent.
  • Text legibility regressions across the interface from antialiasing changes in promoted layers.
  • The mitigation failing twice over: removing will-change to fix memory, and reintroducing a first-frame flash because nothing is promoted before the animation starts — the honest fix is scoped promotion, not a global answer either way.
What can arrive out of order
  • A promotion requested and the first animation frame race: if raster has not completed, the first frame shows blank or stale content — the reason for pre-promotion, and the reason it must still be released.
  • Eviction under memory pressure races the scroll or animation that needs the tile, so content that was fine a moment ago is suddenly not there.
  • An interrupted animation races the cleanup that would have removed the hint, which is how permanent layers accumulate from code that intended them to be temporary.
Security
  • GPU memory is shared across the browser. A page that allocates aggressively degrades other tabs and, on constrained devices, can trigger discards elsewhere — a resource-exhaustion problem the browser mitigates with caps rather than one it prevents.
  • A third-party component or embedded widget can promote heavily without your knowledge, and its layers count against the same budget as yours (Third-Party Scripts and the Supply Chain).
  • Layers are not an isolation boundary. Content in a promoted layer is in the same origin and the same DOM; the separation is purely a rendering optimisation (Origins and the Sandbox).
  • Rendering-resource exhaustion has been used as a denial-of-service vector against browsers and drivers. Engines cap and evict for that reason, and no page-level discipline substitutes for those caps.
Misreads
  • "will-change optimises the element." It hints that a change is coming. The optimisation it usually triggers — a compositing layer — has a memory cost that is invisible in the stylesheet where you wrote it.
  • "More layers means more work done in parallel." Layers are surfaces, not threads. One compositor draws all of them, in order, every frame.
  • "It is only a few kilobytes per element." It is width x height x device-pixel-ratio squared x four bytes, per element, held for the lifetime of the layer. On a high-density display that is megabytes each.
  • "It made the animation smoother, so it worked." Check the frame rate of everything *else*, the memory figure, and the behaviour on a low-end device before concluding that.
  • "Remove all will-change then." Scoped, temporary promotion of the element that is actually animating is correct and valuable. The failure is applying it broadly and permanently.
  • "The GPU has plenty of memory." It is shared with every other tab, every image, every video and the compositor's own buffers, and on integrated graphics it is system memory competing with everything else (CPU or GPU: Two Bets About What Work Looks Like).

Measuring it, and what changes in the field

How you would see this
  • The Layers panel: exact layer count, memory per layer, and the stated reason each was promoted. This is the measurement — everything else is inference.
  • Layer borders in the rendering tools while interacting, which reveals overlap-forced promotions you never declared.
  • GPU process memory in the browser task manager, before and after the change.
  • The Performance panel's compositor track: late frames with an idle main thread point here (A Mental Model of the Devtools).
  • In the field, this appears as memory-related crashes and dropped frames on low-end devices, not as a layer metric — nothing reports layer count from real users (Real User Monitoring).
Slow device, slow network, large data, old tab
  • On a high-density display, every layer costs several times more memory for the same CSS dimensions — the 3x phone is where a desktop-tested layer strategy fails first (The Viewport and Device Pixels).
  • On a memory-constrained device, the budget is reached sooner and eviction is more aggressive, so the failure is not gradual.
  • With a long list, layer count scales with rendered rows — which is another reason virtualisation helps more than promotion does (List Virtualization).
  • In a long-lived tab, per-interaction promotions that are never released accumulate across a session (Long-Lived Clients and Version Skew).
  • On a desktop with a discrete GPU and abundant memory, none of this reproduces, which is precisely why it ships.
What this costs
  • Scoped promotion is more code and more state than a stylesheet declaration: something must add the hint, something must remove it, and the interrupted path must be handled.
  • Not pre-promoting risks a blank or stale first frame at the start of an animation. That is a real visual cost, and the answer is to promote slightly early for the specific element, not broadly for the component.
  • Containment isolates invalidation without allocating a surface, but requires the containment promise to actually hold — and produces clipping bugs when it does not (CSS Containment).
  • Keeping layer counts low can mean accepting main-thread paint for some animations on some devices, which is the correct trade when the alternative is exhausting memory.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • ENGINE-SPECIFICLayer memory caps, eviction policy and whether a will-change hint is honoured at all are engine implementation details: Blink applies documented compositing triggers plus a memory budget and will ignore hints it considers excessive, WebKit rations layer memory far more aggressively on iOS and declines promotions a desktop browser would grant, and Gecko's WebRender does not allocate one texture per layer in the same way — so the same stylesheet produces different memory profiles and different failure points in each.
  • DEVICE-SPECIFICLayer cost scales with the square of the device pixel ratio and competes for whatever memory the device has, so a promotion strategy that is invisible on a 1x desktop with a discrete GPU can exhaust the budget on a 3x phone with integrated graphics — the arithmetic below is the same, the ceiling is not.
  • SIMPLIFIEDThe memory arithmetic here treats a layer as one uncompressed bitmap at four bytes per pixel; real engines tile large layers, may use compressed texture formats, and allocate partial coverage for off-screen regions, so the true figure is usually somewhat lower — but the scaling with area and device pixel ratio, which is the point, is unchanged.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Computer Graphics — texture memory budgets, tiling and eviction as the same problem a game engine solves with a streaming budget rather than with a per-object hint.