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 resets on a hard load and what does not on a route changeThe browser will not reset anything for you. Whatever segmentation you want later has to be stamped onto the beacon at the moment the route changes.What resets on a hard load and what does not on a route changeThe same session, two navigation typesHard navigationnew document, all metrics res…Route change 1no LCP, CLS keeps accruingRoute change 2INP still the session worstYour instrumentationstamps the route, resets what…
The browser will not reset anything for you. Whatever segmentation you want later has to be stamped onto the beacon at the moment the route changes.

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
p75 INP by position in the sessionNothing about the later screens is slower in isolation. They inherit a main thread that has been accumulating listeners, timers and retained DOM for several minutes.p75 INP by position in the sessionSame application, interactions grouped by how many route changes preceded themFirst screen148 msAfter 1 route change196 msAfter 2 to 4 changes284 msAfter 5 or more438 ms0 ms125 ms250 ms375 ms500 msGood 200 ms
Nothing about the later screens is slower in isolation. They inherit a main thread that has been accumulating listeners, timers and retained DOM for several minutes.

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.

What a route commit actually costsPrefetching the bundle on hover removes the 280 ms in the second segment outright; everything else needs real work.What a route commit actually costsClick to painted screen, mid-tier Android, instrumented with the marks above0 ms295 ms590 ms885 ms1,180 msHandler and router work — 150 msRoute bundle fetch — 280 msData fetch — 390 msRender and commit — 240 msPaint — 120 msroute painted 1,180 ms
Prefetching the bundle on hover removes the 280 ms in the second segment outright; everything else needs real work.

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

  1. 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.
  2. Stamp the route template on every beacon using the reset pattern above, and wait 48 hours for volume.
  3. 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.
  4. Pull attribution for the worst template with the attribution build to get the interaction target selector.
  5. 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.
  6. 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.
Where the route-commit budget goes, by entry pointDeep-linked sessions pay for bundles the home entry already had cached. Route-level prefetching is worth more to them than any handler optimisation.Where the route-commit budget goes, by entry pointComposition of the p75 route change on three entry routesEntered on home11%15%48%26%Entered on search13%25%37%26%Entered on a deep link14%31%29%26%HandlerBundle fetchData fetchRender
Deep-linked sessions pay for bundles the home entry already had cached. Route-level prefetching is worth more to them than any handler optimisation.

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 commitRoute updates state.route before 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/8841 and /products/8842 are 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 popstate path often calls the router hook twice in frameworks that also patch pushState. 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 interactionId for 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.visibilityState was hidden.

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.