Soft Navigations & SPA Metrics
A single-page application performs exactly one navigation that the browser recognises. Every route change after that is a DOM mutation, a history entry and a scroll reset — none of which resets a Core Web Vitals metric. As established in Core Web Vitals & Performance Metrics Fundamentals, the assessed metrics are scoped to a page load, so an application that keeps one document alive for a twenty-minute session reports one LCP taken at boot, one CLS accumulated across every screen the user visited, and one INP that is the worst interaction anywhere in that session. This page covers what the browser actually reports for a soft navigation, how to measure route-change latency yourself, and how to attribute the numbers to the route template that earned them.
The practical consequence is severe and widely under-appreciated: a checkout screen that is fast in isolation inherits every long task the product listing queued before it. Teams chase an INP regression on /checkout for weeks before discovering the offending handler lives on the screen users pass through on the way there.
What the browser reports for a soft navigation
Three separate behaviours are worth keeping straight, because they fail in different ways.
LCP stops at the first interaction. The largest-contentful-paint candidate stream ends when the user first scrolls, clicks or types. A route change is almost always preceded by a click, so no route after the first can ever produce an LCP entry. The PerformanceObserver lifecycle and buffered-entry handling is doing exactly what the spec asks; there is simply no candidate stream left to observe.
CLS accumulates for the lifetime of the document. Layout shift windows keep opening and closing, and the reported value is the worst 5-second session window found anywhere in the document’s life. A screen that shifts badly on route four poisons the number for the whole session, including the three screens that behaved. The mechanics are covered in CLS Reduction Strategies.
INP is the worst qualifying interaction in the document. Not per route, not per screen — per document. This is the one that generates the most confused bug reports, because the metric attributes the pain to whichever screen happened to be visible when the beacon flushed.
| Metric | Resets on a route change? | What a long session reports | What to instrument instead |
|---|---|---|---|
| LCP | No — ends at first input | Boot-time value only | Element Timing or a route-painted mark |
| INP | No — document lifetime | Worst interaction anywhere | Per-route interaction latency, stamped with the route |
| CLS | No — accumulates | Worst window across all screens | Per-route shift sum, reset on route change |
| TTFB | No — navigation only | Initial document only | Route data-fetch duration as a custom measure |
Threshold configuration for route-level work
The assessed thresholds do not change — LCP ≤2.5 s, INP ≤200 ms, CLS ≤0.1, all at p75. What changes is the number you hold your team to, because the route-level metric you build is not the assessed metric. Set the internal target tighter than the public threshold so the session-level aggregate has headroom.
| Route-change measure | Good | Needs improvement | Poor | Engineering response |
|---|---|---|---|---|
| Route commit (click → new route painted) | ≤ 500 ms | 500–1000 ms | > 1000 ms | Prefetch the route bundle on intent; stream the shell before data |
| Route data fetch | ≤ 300 ms | 300–800 ms | > 800 ms | Move the query to the edge or cache it; render a skeleton immediately |
| Per-route interaction latency | ≤ 150 ms | 150–350 ms | > 350 ms | Yield inside the handler; defer non-visual work |
| Per-route shift sum | ≤ 0.02 | 0.02–0.08 | > 0.08 | Reserve space for the incoming screen before unmounting the old one |
The internal interaction target of 150 ms exists because the session-level INP the origin is assessed on is the maximum of these, not their average. If every route sits at 190 ms, the session p75 will not be 190 ms — it will be well above it.
Measurement implementation
The pattern that works in every framework: hold a mutable “current route” that the router updates, reset your own per-route accumulators at the moment of the change, and stamp every beacon with the route that was active when the entry was recorded.
// route-metrics.js — framework-agnostic per-route measurement.
const state = {
route: window.location.pathname,
routeStart: performance.now(),
shiftSum: 0,
worstInteraction: 0,
};
const beacons = [];
const queue = (name, value, extra) => {
beacons.push({
name,
value: Math.round(value * 1000) / 1000,
route: state.route,
navType: state.route === document.documentElement.dataset.entryRoute
? 'hard' : 'soft',
ts: Date.now(),
...extra,
});
};
// Per-route layout shift: keep our own sum, reset it when the route changes.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue;
state.shiftSum += entry.value;
}
}).observe({ type: 'layout-shift', buffered: true });
// Per-route interaction latency: the browser gives us every event, we bucket
// it ourselves instead of waiting for the document-scoped INP.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.interactionId) continue;
if (entry.duration > state.worstInteraction) {
state.worstInteraction = entry.duration;
}
}
}).observe({ type: 'event', durationThreshold: 40, buffered: true });
// Call this from the router, AFTER the new route has painted.
export function commitRoute(nextRoute) {
const now = performance.now();
queue('route_shift_sum', state.shiftSum);
queue('route_worst_interaction', state.worstInteraction);
queue('route_dwell', now - state.routeStart);
state.route = nextRoute;
state.routeStart = now;
state.shiftSum = 0;
state.worstInteraction = 0;
}
// Flush once, when the document is actually going away.
addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden' || !beacons.length) return;
navigator.sendBeacon('/rum', JSON.stringify({ beacons }));
beacons.length = 0;
});
Two details decide whether this data is usable. First, commitRoute must run after the new screen has painted, not when the router starts the transition — call it inside a double requestAnimationFrame or your framework’s post-commit hook. Second, the route value must be the template (/products/[slug]), never the resolved URL, or the warehouse fills with millions of single-sample buckets that no percentile can be computed over.
The Soft Navigations API
Chrome ships an experimental Soft Navigations API that lets the browser detect a route change heuristically — a user interaction, followed by a URL change, followed by a DOM modification that paints. When it is enabled, the browser emits soft-navigation performance entries and can attribute subsequent LCP entries to the soft navigation that preceded them.
// Feature-detect: the entry type only exists where the API is enabled.
const supportsSoftNav = PerformanceObserver.supportedEntryTypes
?.includes('soft-navigation');
if (supportsSoftNav) {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.name is the URL after the navigation; startTime is when the
// browser decided a soft navigation had happened.
queue('soft_navigation', entry.startTime, { url: entry.name });
}
}).observe({ type: 'soft-navigation', buffered: true });
}
Treat it as a cross-check, not a foundation. It is Chromium-only, heuristic by design, and its detection rules have changed between releases. Your own router hook knows for certain when a route changed; the browser is guessing. Where the two disagree, the disagreement itself is informative — it usually means your route transition does not paint anything the user would recognise as a new screen. The narrow how-to lives in Using the Soft Navigations API.
Step-by-step debugging workflow
- Confirm the metric is session-scoped, not screen-scoped. Group your existing INP beacons by how many route changes preceded them. If p75 climbs with session depth, the problem is accumulation, not any single screen.
- Stamp the route template on every beacon using the reset pattern above, and wait 48 hours for volume.
- Rank route templates by per-route worst interaction, not by session INP. The two rankings are frequently different, and the first one is the actionable one.
- Pull attribution for the worst template with the attribution build to get the interaction target selector.
- Reproduce with session depth, not on a fresh load: navigate through the same three screens the field data shows, with 4× CPU throttling, before touching the handler.
- Ship, then watch the per-route number and the session INP separately. The per-route metric moves within hours; the session p75 lags by days because it is a maximum over long sessions.
Field-data analysis patterns
Segment by session depth first. It is the dimension that is unique to SPAs and the one no vendor dashboard offers by default.
- Session depth (route changes so far): rising latency with depth points at retained DOM, uncancelled timers or accumulating listeners.
- Entry route vs current route: sessions that entered on a heavy route carry its cost forever. This is the argument for code-splitting by entry point rather than by feature.
- Navigation type (hard vs soft): compare the same route template reached both ways. A route that is fast on hard load and slow on soft load has a client-side data-fetch or hydration problem, not a server one.
- Time since document start: memory pressure and garbage collection correlate with wall-clock session age more tightly than with route count.
Optimization strategies
Prefetch on intent. Fetching the next route’s bundle on mouseenter or touchstart removes the bundle-fetch segment from the critical path entirely, typically 200–400 ms on a mid-tier device. Guard it behind navigator.connection.saveData and an effectiveType check so you are not burning a metered connection speculatively — see Segmenting RUM Data by Effective Connection Type.
Render the shell before the data. Committing a skeleton in the same frame as the route change turns a 1,180 ms blank-screen wait into a 300 ms shell plus a progressive fill. The measured route-commit number improves and, more importantly, the number users perceive improves more.
Reserve the incoming screen’s space. Unmounting the old screen before the new one has dimensions produces a shift on every single route change. Multiply that by a ten-screen session and the CLS the origin is assessed on is dominated by navigation chrome, not content.
Yield inside route transitions. A route commit is a long task like any other. Breaking it with scheduler.yield lets the browser paint the skeleton before the expensive render completes.
Tear down on route change. Remove listeners, cancel in-flight requests, clear intervals. The measured effect on late-session INP is larger than any single handler fix, because it stops the accumulation the whole problem rests on.
Why late-session interactions cost more
The measured pattern — interaction latency rising with session depth — has three mechanical causes, and they respond to different fixes.
Retained DOM. A router that mounts a new screen without unmounting the old one leaves nodes attached and styled. Style recalculation and layout are proportional to the node count the engine has to consider, so every subsequent interaction that touches layout pays for screens the user cannot see. This is the cheapest one to find: log document.getElementsByTagName('*').length alongside your route beacon and look for a line that only goes up.
Listeners and timers that outlive their screen. Every setInterval, IntersectionObserver and delegated listener registered on mount and never torn down keeps running. A polling interval per screen is invisible on screen one and is eight concurrent timers by screen eight, each one waking the main thread on a schedule that has nothing to do with what the user is doing.
Garbage collection pressure. Long sessions accumulate detached nodes, closures over old props and cached responses. The collector runs when it must, and it runs on the main thread. A major collection during an interaction is a long task you did not write, attributed to a handler that did nothing wrong.
The instrumentation for all three is the same: attach a cheap health snapshot to the route beacon and look at how the numbers move with depth.
// Attach to commitRoute() — three cheap counters that explain most drift.
function healthSnapshot() {
const snap = { nodes: document.getElementsByTagName('*').length };
// performance.memory is Chromium-only and quantised, but the trend is real.
if (performance.memory) {
snap.heapMB = Math.round(performance.memory.usedJSHeapSize / 1048576);
}
snap.listeners = window.__listenerCount || 0; // incremented by your own wrapper
return snap;
}
None of this is exotic. It is the ordinary cost of keeping one document alive for the length of a session, and it is invisible to any measurement that only looks at a fresh page load — including every lab run in your CI pipeline.
Failure modes and gotchas
- Stamping the route after the reset. If
commitRouteupdatesstate.routebefore queueing the outgoing metrics, every beacon is attributed to the screen the user just arrived at. The bug is invisible in aggregate and completely inverts the ranking. - Resolved URLs as the route key.
/products/8841and/products/8842are the same template. Aggregating on the raw path produces buckets of one and a p75 that is just the sample itself. - Double-counting on back navigation. The history
popstatepath often calls the router hook twice in frameworks that also patchpushState. Deduplicate on the route value plus a timestamp window. - Assuming a route change resets CLS for the browser too. Your own accumulator resets; the browser’s does not. Never report your per-route sum as “CLS” — it is a different metric and will not match what search consoles show.
- Safari has no INP. Interaction data on Safari comes from Event Timing only, and Safari does not report
interactionIdfor all input types. Segment by browser before comparing route rankings, as noted in Web Vitals API Implementation. - Background tabs freeze timers. A route change measured across a tab-hidden period reports minutes. Discard any route measure taken while
document.visibilityStatewashidden.
CI/CD integration
Route-change latency is testable in the lab in a way session INP is not. Drive a headless browser through a scripted three-route journey, read your own marks, and fail the build on a regression against the stored baseline.
// e2e/route-budget.spec.js — runs in CI against a preview deployment.
const BUDGETS = { '/products/[slug]': 500, '/cart': 600, '/checkout': 700 };
async function measureRoute(page, clickSelector, routeKey) {
await page.evaluate(() => performance.mark('route:start'));
await page.click(clickSelector);
await page.waitForSelector('[data-route-ready]');
const ms = await page.evaluate(() => {
performance.mark('route:end');
const m = performance.measure('route', 'route:start', 'route:end');
return m.duration;
});
if (ms > BUDGETS[routeKey]) {
throw new Error(`${routeKey} route commit ${Math.round(ms)}ms exceeds ${BUDGETS[routeKey]}ms`);
}
return ms;
}
Run it with CPU throttling at 4× so the budget reflects a mid-tier device rather than the CI runner. The same journey, run with throttling disabled, is useful only for catching outright breakage.
FAQ
Why does my SPA report only one LCP for a twenty-screen session?
Because LCP is scoped to the document and stops growing at the first user interaction. Every route change after the first is preceded by a click, so there is no candidate stream left. Measure a per-route paint yourself with Element Timing or a route:painted mark instead.
Does the Soft Navigations API fix this?
Partially, and only in Chromium. It lets the browser attribute LCP entries to a detected soft navigation, which gives you a per-route LCP where it is enabled. It does not reset INP or CLS, and it is heuristic — your router hook is authoritative, the browser is inferring.
Should I reset my own CLS accumulator on every route change?
Yes, for your internal per-route metric, and no for anything you compare against search-console data. They are different measurements: yours is per screen, the assessed one is the worst window in the whole document.
Why is INP worse on later screens when those screens are simpler?
Because INP is a document-scoped maximum and late interactions run on a main thread carrying every listener, timer and retained node the session has accumulated. Tearing down properly on route change usually moves this number more than optimising the handler itself.
What route key should I send on the beacon?
The route template with parameters as placeholders, plus the navigation type. Raw URLs produce unaggregatable buckets, and the navigation type is what lets you compare the same screen reached by hard load and by route change.
Related
- Framework Performance Instrumentation — where to mount the reporter so route hooks and observers survive.
- INP Tracking & Debugging — the interaction metric this page has to work around.
- Measuring Route-Change Latency in React Router — the concrete implementation for one router.
- Using the Soft Navigations API — what the browser reports and how far to trust it.
- Instrumenting User Timing Marks in an SPA — the mark and measure layer underneath route metrics.