Measuring INP for Animated Interactions

Part of Frame Budgeting & 16ms Targets in Performance Budgeting & GPU Architecture.

The problem

A team optimises an interaction until its animation holds 60 fps, and the Interaction to Next Paint number does not move. The animation is smooth, the trace is clean during the motion, and the metric still reports 280 ms at the seventy-fifth percentile.

The disconnect is that INP does not measure the animation. It measures the gap between the user’s input and the first frame that reflects it — everything that happens before the motion starts. An interaction can be followed by the smoothest animation on the web and still score badly, because all the cost is in the setup.

Root cause: INP is three phases, and animation lives in the third

INP decomposes into input delay, processing time and presentation delay.

Input delay is the wait before the handler runs — the main thread finishing whatever it was already doing. Animation contributes here only if something else is animating on the main thread and occupying the frame.

Processing time is the handler itself. An interaction that reads geometry, computes keyframes and writes styles is spending its budget here, and a forced synchronous layout inside the handler is the single most common cause of a bad number.

Presentation delay is style, layout, paint and composite for the frame that finally shows the change. This is where animation setup lands: promoting an element allocates and uploads a texture, @starting-style adds an extra style pass, and a keyframe animation on a layout property runs a full layout before the first frame appears.

So the question for any animated interaction is not “is the animation fast?” but “how much work happens on the frame the animation starts?” A compositor animation answers that with a class flip and a texture that was already allocated. A layout animation answers it with a full pipeline pass over the affected subtree.

One tap, decomposed into the three INP phasesThe animation itself is off-thread in both cases; the difference is entirely in what the starting frame has to do.One tap, decomposed into the three INP phasesINP good threshold 200 msCompositor-ready interaction21 msLayout animation, promoted on the framestyle + layout225 msinput delayhandlerstylecompositestyle + layout
The animation itself is off-thread in both cases; the difference is entirely in what the starting frame has to do.

Step-by-step resolution

Getting from a bad number to a good oneMeasure the phases before changing anything; each phase has a different fix.Getting from a bad number to a good one1Record real interactions with the Event Timing API and note the worstoffenders by selector.Field data names the interaction, lab data explains it2Reproduce it locally with 4x CPU throttling and record a Performancetrace.The interaction appears in the Interactions track3Read the three phases from the trace and identify the dominant one.Delay, processing or presentation4Move animation setup off the interaction frame: promote earlier,precompute values, avoid reads in the handler.The starting frame becomes a class flip5Re-measure in the field, not just in the lab.The seventy-fifth percentile is the number that counts
Measure the phases before changing anything; each phase has a different fix.

Production code pattern

// Intent: an expand interaction whose starting frame costs almost nothing,
// because every expensive step happened before the user tapped.
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    // Promote AHEAD of any interaction, so the texture is already uploaded
    // when the user taps. Released when the panel scrolls away.
    entry.target.style.willChange = entry.isIntersecting ? 'transform' : 'auto';
  }
}, { rootMargin: '200px' });

document.querySelectorAll('.panel').forEach((panel) => observer.observe(panel));

// The handler does one thing: flip a state attribute. No reads, no measuring,
// no keyframe construction — all of that is in the stylesheet.
panelButton.addEventListener('click', () => {
  panel.dataset.state = panel.dataset.state === 'open' ? 'closed' : 'open';
});
/* Intent: the expansion is expressed as compositor-safe properties on a
   fixed-size box, so the starting frame needs no layout. */
.panel {
  /* The box never changes size; the inner content is what moves. */
  block-size: var(--panel-height);
  overflow: clip;
}

.panel__body {
  translate: 0 -100%;
  opacity: 0;
  transition:
    translate 260ms cubic-bezier(0, 0, 0.2, 1),
    opacity 200ms linear;
}

.panel[data-state='open'] .panel__body {
  translate: 0 0;
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .panel__body { transition: opacity 120ms linear; translate: 0 0; }
}

Rendering Impact: translate + opacity — composite only, and the promotion happened before the interaction. The starting frame costs one attribute write and one style pass.

The IntersectionObserver doing the promotion is the piece that most directly moves the metric. Promoting inside the click handler adds a texture allocation and upload to the presentation delay of the very frame being measured; promoting when the panel scrolls into view moves that cost to a frame nobody is timing, following the lifecycle in layer promotion and will-change strategy.

Which phase each animation mistake lands inMatch the fix to the phase; optimising the wrong one changes nothing.Which phase each animation mistake lands inPhaseFixReadinggetBoundingClientRect inthe handlerProcessingPrecompute or read before theinteractionPromoting a layer on clickPresentationPromote on approach with anobserverAnimating height or widthPresentationFixed box, animate thecontentsA long task already runningInput delayBreak up the task, yield tothe schedulerBuilding keyframes inJavaScriptProcessingMove the values into thestylesheetLarge image decoded on thefirst framePresentationPreload and decode ahead oftime
Match the fix to the phase; optimising the wrong one changes nothing.

Verification checklist

Constraints and trade-offs

  • Promoting early holds texture memory for longer; budget it against the interaction improvement rather than promoting everything.
  • Field INP is dominated by the slowest devices in your audience, so a lab measurement on a fast machine can hide a real problem.
  • Some interactions genuinely need to measure geometry; batch those reads before any write, and accept the cost once rather than per frame.
  • INP counts the worst interaction on the page during a session, so one bad menu can dominate an otherwise fast interface.
  • A shorter animation does not improve INP — only the first frame is measured.

Frequently asked questions

Does starting an animation hurt INP?

Starting a compositor animation barely registers: the handler flips a class, style resolves, and the animation runs off-thread. Starting an animation that needs layout, a large texture upload or a synchronous measurement lands entirely inside the interaction’s presentation delay, which is what INP measures.

Why is my INP bad when the animation itself is smooth?

INP measures the delay from input to the next paint, not the smoothness that follows. An interaction that takes 300 ms to produce its first frame and then animates at a perfect 60 fps still reports 300 ms. The fix is in what happens before the first frame, not in the animation.

What INP target should an animated interaction meet?

The same as any other: 200 ms at the seventy-fifth percentile is the good threshold. Animation makes this easier rather than harder, provided the first frame is cheap — a compositor animation typically adds a couple of milliseconds to the interaction frame.


Collecting the field metric

Everything above is lab work, and lab work only tells you whether a specific interaction on a specific device got faster. INP is a field metric: the number that matters is the seventy-fifth percentile across real sessions, on real hardware, under real network conditions.

Collecting it takes a PerformanceObserver on event entries with durationThreshold set, or the small standard web-vitals library, reporting to whatever analytics endpoint you already have. The one field worth capturing beyond the value itself is the interaction target — without it, a slow INP tells you the page is slow and not which control is responsible.

// Intent: report the worst interaction per page view, with enough context
// to find it again in the lab.
let worst = null;
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.interactionId) continue;
    if (!worst || entry.duration > worst.duration) {
      worst = { duration: entry.duration, name: entry.name,
                target: entry.target?.dataset?.testid ?? entry.target?.tagName };
    }
  }
}).observe({ type: 'event', buffered: true, durationThreshold: 40 });

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden' && worst) {
    navigator.sendBeacon('/vitals', JSON.stringify(worst));
  }
});

The durationThreshold of 40 milliseconds keeps the volume manageable while still capturing everything that could plausibly contribute to a bad score. Report on visibilitychange rather than unload, which is unreliable on mobile.