Soft Navigations and SPA Metrics: Measuring Core Web Vitals Per Route
This topic sits within Core Web Vitals & Measurement and deals with the measurement gap every single-page application eventually runs into: the browser's metrics are defined per document, but your users experience the site per route.
When a user lands on /products, clicks through to /products/42, then to /cart, the browser sees one page load. Largest Contentful Paint is recorded once — for the landing view — and stops at the first interaction. Interaction to Next Paint takes the worst interaction across all three views and reports it against /products. Cumulative Layout Shift accumulates its session windows across all three routes and blames the landing URL too. Chrome's User Experience Report (CrUX) does the same. The result is a dashboard where the landing page looks guilty for problems on the cart, and the cart never appears at all.
The thresholds do not change — LCP under 2.5s, INP under 200ms, CLS under 0.1 at the 75th percentile — but the unit they apply to does. This topic covers how to measure route changes ("soft navigations") as first-class page views, how the experimental Soft Navigations API in Chromium approaches the problem, and how to make your own RUM attribute every metric to the route that caused it.
The Measurement Failure This Topic Fixes
There are three distinct symptoms, and teams usually notice them in this order.
Landing pages that are blamed for other pages. A category page shows INP of 310ms at p75 in CrUX, but every interaction on it is cheap in the lab. The slow interaction is the "add to cart" button on the product detail view the user reached by a soft navigation. Field data charges it to the URL in the address bar at load time.
Routes that never appear. Product detail pages reached only by in-app navigation have no CrUX record, no LCP, and no INP of their own. If 70% of product views are soft navigations, 70% of your most commercially important views are unmeasured.
CLS that grows with session length. CLS reports the largest burst of layout shift in any session window (shifts less than one second apart, capped at five seconds). In a long SPA session, there are many windows, and the chance that one of them contains a bad burst grows with time on site. Long engaged sessions look worse than bounces.
Prerequisites
- A router that exposes navigation lifecycle hooks —
router.afterEachin Vue Router,useEffecton location in React Router,onNavigate/afterNavigatein SvelteKit, or the Navigation API'snavigatesuccessevent where available. - The
web-vitalslibrary v4+, and familiarity with its attribution build. For the experimental soft-navigation reporting, the library exposes an opt-inreportSoftNavsoption on Chromium builds where the API is enabled. - Chromium with the Soft Navigations API enabled for lab work:
chrome://flags/#enable-experimental-web-platform-features, or an origin-trial token on your domain for field testing. Treat it as experimental — its heuristics and entry shapes have changed between trials. - A RUM pipeline that can carry a
routedimension separate from the document URL. The RUM beacons and field data collection topic covers the basics.
1. Environment Setup: Define What Counts as a Soft Navigation
Before measuring anything, agree on a definition. Chromium's experimental heuristic requires three things: a user interaction, a same-document URL change via the History or Navigation API, and a DOM modification that results in a paint. That is a sound default. Filtering on the user interaction excludes programmatic redirects and query-string updates from filters or tabs that you probably do not want counted as page views.
// route-tracker.js — a router-agnostic soft-navigation boundary.
let currentRoute = location.pathname;
let navStart = performance.now();
const listeners = new Set();
export function onSoftNavigation(fn) { listeners.add(fn); }
export function markSoftNavigation(nextRoute) {
if (nextRoute === currentRoute) return; // ignore hash/query-only changes
const prev = { route: currentRoute, start: navStart };
currentRoute = nextRoute;
navStart = performance.now();
performance.mark('soft-nav', { detail: { route: nextRoute } });
listeners.forEach((fn) => fn(prev, { route: nextRoute, start: navStart }));
}
// trade-off: keying on pathname alone treats /search?q=a and /search?q=b as the
// same view. For search-heavy apps, include the parameters that change the
// rendered template, but never free-text values (cardinality and privacy).
Wire markSoftNavigation into the router's after-navigation hook so it fires once the new route has committed, not when the click happens.
2. Capture a Baseline per Route
Before changing anything, split your existing RUM by the route active at the time each metric was finalised. For INP, record the route at the time of the worst interaction; for CLS, record which route each session window began in. Even this rough cut usually reveals that one or two in-app routes own most of the failures charged to the landing page.
3. Isolate the Bottleneck: Which Metric, Which Route
Treat each metric separately because each has a different measurement gap:
- LCP is not measured at all for soft navigations by the standard API. You need either the experimental soft-navigation LCP entries or your own "route contentful paint" built from element timing — see measuring LCP after client-side route changes.
- INP is measured, but attributed to the document. The fix is attribution, not measurement — covered in attributing INP to the right route in an SPA.
- CLS is measured and accumulated across routes; you need to reset or split its windows on navigation, explained in why SPA CLS accumulates across routes.
- Route transition time — click to new view painted — is not a Core Web Vital, but it is the metric users feel most in SPAs. Tracking route transition time with User Timing shows how to measure it consistently.
4. Apply the Fix: Per-Route Metric Reporting
The core change is to finalise and send each metric at every soft-navigation boundary, then start fresh for the next route.
import { onSoftNavigation } from './route-tracker.js';
// Per-route accumulators, reset at every soft-navigation boundary.
let route = { inp: 0, interactions: 0, shifts: 0 };
// INP: observe Event Timing directly. web-vitals' onINP reports the PAGE-level
// worst value, which never decreases — useless for a later, faster route.
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (!e.interactionId) continue;
route.interactions += 1;
route.inp = Math.max(route.inp, e.duration);
}
}).observe({ type: 'event', durationThreshold: 16, buffered: true });
// CLS: sum unexpected shifts that happened while this route was active.
new PerformanceObserver((list) => {
for (const e of list.getEntries()) if (!e.hadRecentInput) route.shifts += e.value;
}).observe({ type: 'layout-shift', buffered: true });
onSoftNavigation((prev) => {
send({ route: prev.route, inp: Math.round(route.inp), interactions: route.interactions,
cls: +route.shifts.toFixed(3) });
route = { inp: 0, interactions: 0, shifts: 0 };
});
// trade-off: summing shifts per route overstates CLS on long-lived routes,
// because the official metric keeps only the worst session window. Use the
// window-splitting approach in the CLS guide when the route stays open for minutes.
Note that the "worst interaction per route" here diverges slightly from the official INP definition, which ignores one outlier for every 50 interactions. For routes with few interactions — the common case — the two are identical; track interactions so you know when they are not.
Deconstructing an SPA Route Change
A soft navigation has its own phase model, analogous to a page load but without DNS, TLS or document parsing. Budget each phase so the whole transition — click to new content painted — stays under roughly 1s, the point at which users perceive a navigation rather than a reaction.
| Phase | Starts | Ends | Budget | Typical bottleneck |
|---|---|---|---|---|
| Interaction | pointer/keyboard input | router handler starts | < 50ms | long task in progress |
| Route code | router handler | route chunk evaluated | < 200ms | un-prefetched chunk download |
| Data | chunk evaluated | data resolved | < 400ms | serial API waterfall |
| Render | data resolved | new content painted | < 200ms | large component tree commit |
The interaction phase is also the INP interaction for the click, so a slow router handler fails INP and transition time at once. The data phase is usually the biggest and the easiest to parallelise: start the route's data request in the same tick as the chunk request, not after the component mounts.
Advanced Diagnostics and Edge Cases
Navigations without interactions. A redirect after login, an auto-advancing wizard, or a router.replace to normalise a URL all change the route without user input. Counting them inflates view counts and creates routes with zero interactions and misleading LCP. Gate on a recent interaction (within, say, 1 second) unless the navigation is a deliberate user-visible view.
Back and forward in SPAs. popstate navigations restore a route whose data may be cached. Their transitions are fast and will flatter your averages; tag them so you can analyse them separately, and do not confuse them with bfcache restores of the whole document, covered in the back/forward cache topic.
Overlapping navigations. Users click again before the previous route has painted. Finalise the abandoned route with an aborted: true flag rather than letting its metrics leak into the next one.
Hydration on the landing route. The first route is a hard navigation and carries hydration cost the others do not. Comparing landing-route INP with in-app routes without accounting for this makes in-app routes look better than they are relative to their own code.
Prerendered routes. With speculation rules, a "navigation" may activate a prerendered document — a hard navigation that looks instantaneous. Use activationStart to normalise its timestamps before comparing with soft navigations.
Validation and Budgeting
Validate per-route attribution by deliberately breaking one route in a staging build — add a 150ms busy loop to a handler on /cart — and confirm that only /cart regresses in your RUM, not the landing route. Then set budgets per route template rather than per URL:
# perf-budgets.yml — evaluated nightly against the last 7 days of RUM.
routes:
/products: { lcp_p75: 2500, inp_p75: 200, cls_p75: 0.1 }
/products/:id: { route_lcp_p75: 1500, inp_p75: 200, transition_p75: 1000 }
/cart: { inp_p75: 150, transition_p75: 800 }
# trade-off: route-level budgets need enough traffic per template for a stable
# p75 — roughly 500+ samples a week. Merge low-traffic routes into one bucket
# rather than alerting on noise.
Router Integration Notes by Framework
The boundary hook you choose determines what your per-route numbers mean, and each router exposes a slightly different set.
Vue Router (Vue, Nuxt). router.beforeEach fires when the navigation is requested, beforeResolve after async route components and in-component guards resolve, and afterEach once the navigation is confirmed — but before the DOM has updated. For "route committed" use afterEach plus nextTick(); for "route painted" add a requestAnimationFrame after that. Nuxt adds page:start and page:finish hooks that already account for <Suspense> resolution, which makes page:finish the cleaner end marker.
React Router (v6.4+ data routers). Loaders run before the route renders, so useNavigation().state moving from loading to idle marks data readiness. For the start, capture the click in a capture-phase listener; the router has no "navigation requested" callback that precedes loader execution in every mode. A useEffect keyed on location.key runs after commit and is the right place for markSoftNavigation.
Next.js App Router. There is no public router event API in the App Router. Use usePathname() and useSearchParams() in a client component mounted in the root layout; its effect runs after the new segment commits. Server components streaming in after that point will not be captured by the effect, so pair it with Element Timing on the segment's hero for an end marker.
SvelteKit. beforeNavigate and afterNavigate bracket the navigation cleanly, and navigation.type distinguishes link, popstate, goto and form. Filter on link and form to count only user-initiated views.
The Navigation API. Where supported, navigation.addEventListener('navigatesuccess', …) fires after the intercepted navigation's handler promise resolves, which gives you a framework-independent end point. It is the most promising long-term primitive, but coverage is still incomplete across engines, so keep the router-specific path as the default.
Whichever hook you use, test the boundary by navigating rapidly between three routes: if any metric is attributed to the wrong route in that test, the hook fires too early or too late.
FAQ
Will CrUX ever report soft navigations separately?
That depends on the Soft Navigations API leaving its experimental phase and on Chrome deciding to aggregate it, neither of which is settled. Plan as if CrUX will continue to attribute everything to the landing URL for the foreseeable future. Your own RUM is the only place per-route numbers live today, which is a strong reason to instrument it rather than wait.
Should I convert my SPA to multi-page navigation to fix the metrics?
Not to fix the metrics — that would be optimising the measurement rather than the experience. There are real performance arguments for multi-page architectures with view transitions and prerendering, but they should be judged on user-facing speed. Measuring soft navigations properly is a prerequisite for making that comparison honestly.
Does Google Search use per-route data for ranking?
Search uses CrUX, which is per document URL. Pages that are only reached via soft navigation and never loaded directly have little or no data of their own. That is another reason landing-page experience matters disproportionately: it is the only part of an SPA session that field data assigns to the right URL.
How many soft navigations per session are typical, and does it matter?
On content sites, sessions average one to three views; on dashboards and storefronts it is common to see ten or more in-app routes per session. It matters because every additional route adds interactions to the INP pool and layout-shift windows to the CLS pool, while LCP stays fixed to the landing view. The more routes per session, the further the official numbers drift from what any single view is like, and the more value per-route measurement adds. Record views-per-session alongside your metrics so the drift is visible rather than assumed.
Guides in This Topic
- Measuring LCP after client-side route changes — a route-level contentful paint you can trust.
- Attributing INP to the right route in an SPA — stop charging the cart's interactions to the homepage.
- Tracking route transition time with User Timing — click-to-content as a measured, budgeted number.
- Why SPA CLS accumulates across routes — splitting session windows at navigation boundaries.
Related
- Improving INP for complex single page applications — the optimisation side of the same applications.
- Long Animation Frames (LoAF) API — script attribution that pairs naturally with route attribution.
- Code-splitting Vue Router routes — shrinking the route-code phase of every transition.
- CrUX vs your own RUM: why the numbers differ — reconciling per-route data with the public dataset.