A will-change Lifecycle Driven by IntersectionObserver

Part of Layer Promotion & will-change Strategy in Performance Budgeting & GPU Architecture.

The problem

will-change: transform in a stylesheet rule is the most expensive one-line performance optimisation available. It works exactly as documented — the element is promoted, the animation is smooth — and it does so for every element the selector matches, from the moment the stylesheet loads until the page is closed.

On a grid of forty cards at a device pixel ratio of 3, that is somewhere north of eighty megabytes of GPU texture memory held permanently, so that a hover animation lasting 180 milliseconds can start without a hiccup. On a desktop it goes unnoticed. On a mid-tier phone the browser starts evicting textures, and the symptom is that unrelated parts of the page flicker as they are re-rastered.

Root cause: a hint with no expiry

will-change tells the browser to prepare for a change. It does not tell it when the change is over, because CSS has no way to express that. The declaration is a state, and states in stylesheets last as long as their selector matches.

That mismatch is the whole problem. Promotion is inherently temporal — there is a moment before the animation when the texture should exist, and a moment after when it should not — and it is being expressed in a language that has no concept of “for the next 200 milliseconds”.

The fix is to move the promotion to something that does have a lifecycle. An IntersectionObserver knows when an element approaches the viewport and when it leaves; an animationend listener knows when a one-shot effect finishes. Between them they bracket the window in which the texture earns its cost.

The observer is also cheap in exactly the way a scroll handler is not: it is dispatched from the compositor, batched, and delivered off the critical path, so the promotion machinery does not itself compete with the animation it is preparing for.

Texture memory held, by promotion strategyThe same forty-card grid, with the same animations, under three strategies.Texture memory held, by promotion strategyStatic will-change on all 40 cardsRoughly 83 MB held for the life of thepagePromoted while within 200px of the viewportAround 12 MB, tracking the scrollpositionPromoted only while animatingUnder 3 MB, released on animationendNo promotion at all0 MB, but the first frame of eachanimation stutters
The same forty-card grid, with the same animations, under three strategies.

Step-by-step resolution

Replacing a static promotion with a managed oneEach step is independently verifiable in the Layers panel.Replacing a static promotion with a managed one1Delete every will-change declaration from the stylesheet.Layer count drops to the browser's own promotions2Create one observer with a root margin and observe the animationcandidates.One observer for many elements, not one each3Promote on entry and demote on exit inside the callback.Memory now tracks the scroll position4For one-shot effects, demote on animationend as well.Texture freed even while the element is still visible5Watch the Layers panel while scrolling to confirm the count rises andfalls.
Each step is independently verifiable in the Layers panel.

Production code pattern

// Intent: one observer, a promotion window bracketed by approach and exit,
// and an extra release for one-shot animations that finish early.
const PROMOTE_MARGIN = '200px';

const promotionObserver = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      const el = entry.target;
      if (entry.isIntersecting) {
        // Allocate before the element can animate, not during.
        el.style.willChange = 'transform, opacity';
      } else {
        // Release as soon as it cannot animate again without re-entering.
        el.style.willChange = 'auto';
      }
    }
  },
  { rootMargin: PROMOTE_MARGIN }
);

document.querySelectorAll('[data-animates]').forEach((el) => {
  promotionObserver.observe(el);

  // One-shot effects can release even earlier: the moment they finish.
  el.addEventListener('animationend', () => {
    if (!el.dataset.loops) el.style.willChange = 'auto';
  });
});

// Tear down with the component, or the observer keeps every target alive.
export function teardown(root) {
  root.querySelectorAll('[data-animates]').forEach((el) => {
    promotionObserver.unobserve(el);
    el.style.willChange = 'auto';
  });
}
/* Intent: no promotion in the stylesheet at all. The animation is declared
   here; the layer lifecycle is owned by the observer above. */
[data-animates] {
  transition:
    translate 240ms cubic-bezier(0, 0, 0.2, 1),
    opacity 200ms linear;
}

[data-animates][data-state='in'] { translate: 0 0; opacity: 1; }

/* Accessibility gate: with no motion, there is nothing to promote for.
   Releasing the hint here keeps memory at zero for these users. */
@media (prefers-reduced-motion: reduce) {
  [data-animates] {
    transition: opacity 120ms linear;
    translate: 0 0;
    will-change: auto;
  }
}

Rendering Impact: the animation is translate + opacity, composite only. The observer callback runs off the critical path and does one style write per element per crossing, which is orders of magnitude less work than a permanent texture.

The reduced-motion block matters more than it looks. Under reduce there is no travel to prepare for, so holding a texture is pure waste — and because the stylesheet declaration wins over the inline style only if it is more specific, using will-change: auto there is a deliberate belt-and-braces measure rather than a redundancy.

When to promote and when to releaseEvery promotion needs an answer in both columns before it ships.When to promote and when to releasePromote whenRelease whenScroll-triggeredrevealWithin 200px of the viewportIt leaves that regionHover or focuseffectThe element is on screenIt scrolls awayOne-shotentranceJust before the class is appliedanimationend firesDrag gesturepointerdownpointerup or cancelInfiniteambient loopIt becomes visibleIt leaves the viewportModal oroverlayIt is about to openThe exit transition finishes
Every promotion needs an answer in both columns before it ships.

Verification checklist

Constraints and trade-offs

  • An observer holds strong references to its targets; without unobserve on teardown, removed components leak.
  • Too small a root margin promotes too late and the first frame stutters; too large a margin promotes half the page.
  • Inline styles set by the observer outrank stylesheet rules, which can surprise someone debugging a will-change they cannot find in the CSS.
  • Elements that overlap a promoted element may be promoted implicitly, so the layer count can exceed the number you asked for — check the compositing reasons in the panel.
  • On very short lists the whole cost is theoretical; measure before adding the machinery.

Frequently asked questions

Why not just leave will-change on permanently?

Because each promotion holds a texture sized by the element’s area multiplied by the square of the device pixel ratio, for the entire life of the page. A grid of promoted cards can hold tens of megabytes of GPU memory for animations that run for a few hundred milliseconds.

How far ahead should the observer promote?

A root margin of roughly 200 pixels is a good default: far enough that the allocation and upload finish before the element can be interacted with or scrolled into view, close enough that only a handful of elements are promoted at once.

Does removing will-change destroy the layer immediately?

Not necessarily — the browser may keep the layer briefly if other compositing reasons still apply, and it reclaims memory on its own schedule. What removal guarantees is that your code is no longer the reason the layer exists.


Promotion for interactions that have no approach

The observer pattern assumes the element scrolls into view before it can animate, which covers reveals, hovers on list items and anything below the fold. Two common interactions do not fit it.

A drag gesture has an unambiguous start: pointerdown. Promote there and release on pointerup or pointercancel. The promotion happens one frame before any movement, which is early enough — and because a drag is a sustained interaction, the texture earns its cost many times over.

A modal or overlay is not in the viewport at all until it opens. Promote at the moment the open is requested, before the element becomes visible, so the allocation lands on the frame that was already going to be busy rather than on the first animated frame. Release when the exit transition finishes, which the transitionend event reports precisely.

Both share the shape of the observer version — an explicit start, an explicit release, and no promotion in between — which is the real lesson. will-change is not a property of an element; it is a property of a window in time, and every use of it needs code that opens and closes that window. Where no such pair of events exists, that is usually a sign the element does not need promoting at all.