How View Transitions Affect INP — Measurement and Mitigation
document.startViewTransition() runs its snapshot capture and DOM-update callback on the main thread, inside the interaction that triggered it — which means every millisecond of that work is charged to the latency INP measures, as covered in the parent guide on Core Web Vitals and animated interfaces. This page is the hands-on workflow: measure the baseline, instrument the transition, shrink the callback, skip the transition where it cannot be afforded, and prove the improvement in field data. It belongs to the Animation Performance Profiling & Optimization section and builds on how the View Transition API works under the hood.
When to use this approach
Use this workflow when any of the following holds; otherwise a simpler guard is enough:
- Field INP is over 200 ms at p75 and the slow interactions correlate with navigations or UI swaps — the full five-step workflow below tells you whether transitions are the cause and by how much.
- You are about to adopt view transitions on an interaction-heavy surface (tab switches, gallery paging, filter panels) — instrument first, ship second, so the before/after delta is real data rather than a guess.
- INP is comfortably green and transitions wrap only rare, full-page navigations — skip the instrumentation and just apply Step 4’s reduced-motion and slow-device guards defensively.
- Your transition callbacks already
awaitnetwork work — go straight to Step 3; nothing else matters until the callback is synchronous.
The one thing not to do is reach for CSS-only mitigation: setting ::view-transition-* animation durations to near zero shortens the crossfade, which is the cheap part. The expensive part — snapshot capture plus a rendering pause around the callback — only goes away when the startViewTransition() call itself is skipped or slimmed.
Implementation
Step 1: Measure baseline INP in the field
Before touching any transition code, capture what real users experience. Use the web-vitals library’s attribution build so each INP report names the interaction target, and add one custom dimension: whether a view transition was active when the interaction ran.
import { onINP } from 'web-vitals/attribution';
let transitionActive = false;
export function markTransition(active) { transitionActive = active; }
onINP((metric) => {
navigator.sendBeacon('/vitals', JSON.stringify({
value: metric.value,
rating: metric.rating,
target: metric.attribution.interactionTarget,
eventType: metric.attribution.interactionType,
// The dimension that makes this analysis possible:
viewTransition: transitionActive,
}));
});
Let this run for enough sessions to produce a stable p75, segmented by the viewTransition flag. Two clean distributions — with and without — is the baseline everything else is judged against. If you also see general scroll jank muddying the picture, the profiling scroll animations in Chrome DevTools workflow separates rendering-pipeline noise from interaction latency.
Step 2: Instrument startViewTransition
Wrap every call site in a helper that flips the flag from Step 1 and records how long each phase takes. The ViewTransition object exposes three promises that map directly onto the timeline in the diagram above: updateCallbackDone (callback finished), ready (pseudo-elements built, animation about to start), and finished (animation done, new state fully interactive).
export async function timedTransition(updateDOM) {
if (!document.startViewTransition) {
updateDOM();
return;
}
markTransition(true);
performance.mark('vt-start');
const vt = document.startViewTransition(() => {
performance.mark('vt-callback-start');
updateDOM(); // must stay synchronous — see Step 3
performance.mark('vt-callback-end');
});
vt.ready.then(() => performance.mark('vt-ready'));
try {
await vt.finished;
} finally {
performance.mark('vt-finished');
performance.measure('vt-callback', 'vt-callback-start', 'vt-callback-end');
performance.measure('vt-blocking', 'vt-start', 'vt-ready');
markTransition(false);
}
}
The vt-blocking measure is the number that matters for INP: it spans old-snapshot capture, the callback, new-snapshot capture, and pseudo-element construction — the full rendering pause. On mid-range hardware, treat anything above ~100 ms as a defect to be fixed in Step 3, and anything above ~50 ms as a candidate for Step 4’s adaptive skipping.
Step 3: Keep the DOM-update callback small
The callback runs with rendering suppressed, and its returned promise extends that suppression. Three rules keep it cheap:
Do all async work before the transition, never inside it. A callback that awaits a fetch holds the page frozen until the network answers (or the browser’s ~4-second internal timeout aborts the transition). Restructure so the transition wraps only the swap:
// Bad: rendering paused for the entire network round-trip
document.startViewTransition(async () => {
const html = await fetch(url).then((r) => r.text());
container.innerHTML = html;
});
// Good: fetch first, transition around the synchronous swap
const html = await fetch(url).then((r) => r.text());
await timedTransition(() => {
container.innerHTML = html;
});
Swap the minimum DOM. Replacing a whole page container forces style and layout for everything; replacing the panel that actually changed keeps the new-snapshot capture proportional. In SPA page-swap animation patterns this means transitioning the route outlet, not document.body.
Budget view-transition-name assignments. Every named element is snapshotted twice and gets its own pseudo-element group. A list where all 30 cards carry names costs an order of magnitude more capture time than a single named container morphing as one unit. Add names only to elements that visibly morph across the change; let everything else ride the root crossfade.
Step 4: Skip transitions on slow devices and reduced motion
A transition the device cannot afford is worse than no transition. Gate the call on three signals — user preference, static device class, and observed runtime behavior:
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
function deviceLooksSlow() {
return (navigator.hardwareConcurrency ?? 8) <= 4
|| (navigator.deviceMemory ?? 8) <= 4;
}
// Adaptive: count long animation frames (Chromium 123+) and give up
let jankStrikes = 0;
if ('PerformanceObserver' in window) {
try {
new PerformanceObserver((list) => {
jankStrikes += list.getEntries().filter((e) => e.duration > 150).length;
}).observe({ type: 'long-animation-frame' });
} catch { /* entry type unsupported — keep static signals only */ }
}
export function shouldTransition() {
return !reducedMotion.matches
&& !deviceLooksSlow()
&& jankStrikes < 3;
}
Then route every call through the guard:
if (shouldTransition() && document.startViewTransition) {
await timedTransition(updateDOM);
} else {
updateDOM(); // instant swap — no snapshots, no rendering pause
}
The reduced-motion branch is an accessibility requirement, not just a performance win — skipping the call in JavaScript avoids snapshot allocation entirely, where CSS duration overrides do not, as detailed in implementing prefers-reduced-motion in scroll-driven animations. Note that matchMedia returns a live MediaQueryList, so checking .matches at call time picks up mid-session OS changes without extra plumbing.
Verification
Prove the improvement in the same terms the baseline was captured in:
- Lab first. DevTools → Performance panel → enable 4x CPU throttling → record while triggering the transition. Find the interaction marker and read the time to the next frame presentation. Compare against a recording with the guard forced off. The
vt-blockingmeasure from Step 2 appears in the Timings track, making the pause directly visible. - Check the phase split. In the recording, confirm the callback (
vt-callback) is a few milliseconds and snapshot capture dominates. If the callback dominates, Step 3 is not finished. - Assert in CI. A scripted Playwright interaction with a duration budget (Event Timing
durationbelow ~250 ms on the CI machine) catches regressions before they ship; the full CI budget setup lives in the parent Core Web Vitals and animated interfaces guide. - Field last, and decisively. After a full release cycle, compare p75 INP for
viewTransition: trueinteractions before and after the change. Success looks like the two segments converging; a persistent 100 ms+ gap means the transition is still too heavy for part of your device population — tighten thedeviceLooksSlow()thresholds. - Console sanity check at any time:
performance.getEntriesByName('vt-blocking').map((m) => m.duration)on a live session shows the distribution of rendering pauses this page has actually incurred.
Edge cases and gotchas
The INP entry may land on the transition’s first animation frame. Event Timing measures interaction-to-next-paint, and during a transition the “next paint” is the first frame of the crossfade. Slow interactions therefore cluster near your vt-blocking duration, not the full animation length — do not subtract the crossfade duration from the reported values; it was never included.
Interactions during the crossfade hit the snapshot overlay. While ::view-transition pseudo-elements cover the viewport, clicks target the overlay rather than the live DOM. A user who clicks twice quickly gets the second interaction queued or lost, and its latency measured against a busy main thread. Keep animations in the 200–350 ms band and never disable pointer events manually on top — that doubles the dead zone.
A rejected callback still runs finished. If updateDOM() throws, the transition is skipped but vt.finished still settles (it rejects with the callback’s error). The finally block in Step 2’s helper exists so the transitionActive flag cannot get stuck on true and poison your analytics segmentation.
Concurrent transitions cancel each other. Calling startViewTransition() while one is in flight skips the earlier transition’s animation immediately (its finished promise settles after the skip). Rapid tab-switchers effectively get instant swaps — which is fine — but your timing marks will interleave; use unique mark names per invocation if you need clean concurrent traces.
Cross-document transitions have no callback to instrument. Navigations animated via the @view-transition at-rule never run a JavaScript update callback, so vt-callback has no equivalent; the cost shows up as delayed first render on the destination page instead. The differences are mapped in same-document vs cross-document view transitions.
Browser-specific notes
Chrome / Edge (Chromium). Same-document startViewTransition() has shipped since Chrome 111, and cross-document @view-transition since Chrome 126. The long-animation-frame observer used in Step 4 requires Chromium 123+. Chromium is also where INP field data predominantly comes from, so this is the engine to optimize against.
Safari. Same-document view transitions shipped in Safari 18, cross-document in 18.2. Event Timing support is partial, so the Step 1 beacon may report fewer entries from WebKit users; rely on the performance.measure marks (universally supported) for phase timing there. Test snapshot-heavy pages on real iPhones — capture cost on mobile WebKit can exceed desktop Chromium by several multiples.
Firefox. Same-document view transitions are in development (Firefox 144+ work in progress); until they ship, document.startViewTransition is undefined and the helper in Step 2 falls through to the instant swap automatically — no separate feature detection needed, though the view transition browser support matrix is the reference for guard patterns if you want an explicit @supports story.
All engines. navigator.deviceMemory is Chromium-only and hardwareConcurrency is clamped for privacy in some configurations — that is why Step 4 uses nullish fallbacks that default to allowing the transition, with the runtime jank counter as the corrective signal.
Related
- Compositor-Safe Properties & will-change Budgeting — why the crossfade itself is cheap and the snapshot phase is not
- Diagnosing Main-Thread Jank in the Performance Panel — trace-reading skills used in the verification steps
- Building Reduced-Motion Variants of View Transitions — designing the no-transition path rather than merely falling back to it
- View Transition API vs FLIP Technique Comparison — how the manual FLIP approach distributes the same main-thread cost differently
- Implementing View Transitions for React Router — a concrete router integration to hang this instrumentation on