Auditing will-change Usage in a Codebase

will-change is a promise that something is about to change, and the browser reserves a layer against it. A promise that is never kept is a standing allocation for no benefit — and because the declaration looks like an optimisation, it accumulates rather than being questioned. This guide is a short audit: what to search for, how to classify what you find, and what to do with each category. It sits under compositor-safe properties and will-change.

When to use this approach

  • A layer figure that is higher than the effect count explains. Standing hints are one of the two main causes, alongside overlap promotion.
  • An older codebase. Promotion hacks predate will-change and are still present in most projects of any age.
  • Before adding effects to a page that is near its budget. Reclaiming capacity from hints that do nothing is the cheapest headroom available.
  • After inheriting a project. The hints in it were added for reasons that may no longer exist and are rarely documented.
Four verdicts per declaration Every declaration is one of four things: redundant because the element already animates, stale from a problem that no longer exists, permanent because the selector always matches, or correct because it is scoped to an interaction. Only the last is worth keeping as written. Redundant the element already runs a transform animation, so it is promoted anyway — delete Stale added for a jank problem that a later refactor removed — delete Permanent the selector always matches, so the allocation never ends — scope it Correct — scoped to hover, focus or an active state; keep as written

Implementation

1. Search for more than the property

What to search for Four searches. will-change is the obvious one. translateZ and translate3d catch the legacy hack. backface-visibility catches an older variant. And a search for the hint inside JavaScript catches the ones applied imperatively, which no CSS search will find. will-change the obvious one, and rarely the whole list translateZ(0) | translate3d the legacy hack — always removable now backface-visibility: hidden an older variant of the same hack style.willChange applied in script — no CSS search finds these
rg -n "will-change" --type css --type scss
rg -n "translateZ\(0\)|translate3d\(0" --type css --type scss
rg -n "backface-visibility:\s*hidden" --type css --type scss
rg -n "willChange" --type js --type ts

The legacy hacks force promotion just as effectively and will not appear in a search for the modern property. In most codebases they outnumber it.

2. Classify each declaration

For every hit, ask two questions. Does the element already run an animation on the hinted property — if so the hint is redundant, because a running animation promotes the element anyway. And can the selector ever stop matching — if not, the allocation is permanent.

Those two questions sort every declaration into the four categories above without needing to understand the history of any of them.

3. Delete the redundant and the stale

/* redundant — the animation already promotes it */
.reveal {
  will-change: transform;      /* delete */
  animation: rise linear both;
  animation-timeline: view();
}

Deleting a redundant hint changes nothing about behaviour and removes nothing but the allocation, which makes these the safest edits in the audit.

4. Scope the permanent ones

Scoping a permanent hint The same declaration in two forms. Applied to the class, the allocation exists for every matching element for the whole session. Applied to the interaction states, it exists only while the reader is engaging with that element — which is the entire window in which the hint has any value. Permanent .card {{ will-change: transform; }} held for the whole session Scoped .card:hover, .card:focus-within {{ will-change: transform; }} held briefly exactly while it is useful The hint's only job is to promote slightly before the animation starts. Held longer, it is pure cost.
/* before */
.card { will-change: transform; }

/* after */
.card:hover,
.card:focus-within { will-change: transform; }

The hint’s value is promoting slightly ahead of an animation starting. Scoping it to the interaction that precedes the animation keeps that value and drops everything else.

5. Handle the imperative ones

el.style.willChange = 'transform';
requestAnimationFrame(() => el.animate(frames, opts));
animation.finished.finally(() => { el.style.willChange = ''; });

A hint applied in script must be removed in script. The finally handler is the part that gets omitted, which turns a scoped hint into a permanent one at runtime where no CSS search will find it.

Verification

Measure the layer figure before and after the audit on the page with the most hints. The reduction is usually immediate and satisfying, and it confirms that the declarations you deleted were doing what you thought.

Then confirm nothing regressed. Interact with each element whose hint you scoped and watch for a first-frame stutter — that is the promotion delay the hint existed to remove, and its return means the scope is too tight rather than that the hint was needed permanently.

Run the searches again after the edits. Imperative hints in particular tend to hide in files that the first pass did not cover.

Edge cases and gotchas

A hint on a parent that promotes children. will-change on a container can create a stacking context and change what is promoted beneath it. Removing it can change more than the container’s own layer.

Hints in third-party CSS. Component libraries add them, and they cannot be edited directly. An override that resets will-change: auto on the specific elements is the practical response, applied after the library’s own styles.

Hints on elements that animate rarely but critically. A modal that must open without a stutter is a legitimate case for a hint scoped to a class applied just before opening — which is a scoped hint, just scoped by state rather than by pseudo-class.

will-change with multiple values. Each named property may add its own constraint, and will-change: transform, opacity, filter can be more expensive than any one of them. Name only what actually changes.

Removing a hint that was masking a real problem. If deleting a hint reintroduces jank, the underlying animation is doing something expensive. The hint was hiding it, and the fix belongs in the animation.

Browser-specific notes

All three engines honour will-change and all three promote on running transform animations, so the redundancy rule holds everywhere.

Chromium’s Layers panel reports the promotion reason, which makes the classification step almost mechanical — a layer attributed to a will-change hint on an element with no running animation is a declaration to scope or delete.

Safari’s Web Inspector reports composited elements with less detail but enough to spot the same pattern. Firefox has no layer viewer, which is why the audit is done in Chromium; the deletions benefit every engine equally.

The legacy hacks behave the same everywhere and are equally unnecessary everywhere. There is no engine that still requires translateZ(0) to promote an element that a modern hint or a running animation would promote.

Frequently Asked Questions

Is will-change ever the right permanent choice?

Almost never. The closest legitimate case is a single element that animates on nearly every interaction and whose first frame must be perfect — a drag handle in an editor, for example. Even there, applying the hint on pointer-down is usually early enough, and it is the version that does not hold a layer while the tool is idle.

How much does one standing hint actually cost?

The element’s painted area times four bytes times the square of the device pixel ratio. For a small card that is under a megabyte on a desktop and a few megabytes on a phone. The problem is rarely one hint; it is a class rule matching thirty elements, which is where the same arithmetic becomes a real number.

Should the audit be automated?

The search should be; the classification should not. A lint rule that flags will-change on a selector with no interaction pseudo-class catches the permanent category automatically and is worth adding. Deciding whether a specific hint is redundant needs context about what else the element does.

What if removing hints makes the first frame stutter?

That is the promotion delay, and it means the hint was doing its job. The fix is to scope it to something that happens slightly earlier — hover before click, focus before activation — rather than to restore it permanently. Where nothing happens earlier, a permanent hint on that one element is a reasonable exception.

How often is this worth repeating?

Once a year on a stable codebase, and after any major dependency upgrade that touches components with animation. Hints accumulate slowly and through ordinary work — someone sees a stutter, adds a hint, the stutter goes away, and the hint stays after the underlying cause is refactored out.

The audit is short enough that attaching it to an existing rhythm works better than scheduling it: the release where you upgrade the component library, or the quarter where you review performance budgets generally.

Should I add a lint rule instead?

Add both. A rule that flags will-change on a selector with no interaction pseudo-class catches the permanent category as it is written, which is far cheaper than catching it a year later. It will not catch the legacy hacks or the imperative assignments, which is why the manual pass still has a job — but it will stop the largest category from growing between passes.


Up: Compositor-Safe Properties & will-change