Back to blog

Soft Navigations: Measuring SPA Performance the Browser Way

Chrome's soft navigation heuristics finally let Core Web Vitals attach to client-side route changes. Here is how the API works and how to measure it without surveillance.

For a decade, Core Web Vitals only described the first page a visitor loaded. Every route change after that in a single-page application was invisible to the metrics. Chrome's soft navigations experiment closes that gap, and it changes how privacy-first analytics should measure real-user performance.

The blind spot soft navigations fix

A traditional hard navigation unloads the document and loads a new one. The browser resets its performance timeline, fires a fresh LCP, and starts counting CLS and INP from zero. Field tools like CrUX attribute everything to that one URL.

SPAs do not work this way. After the initial load, React Router, the Next.js App Router, or SvelteKit swap content in place using the History API. No document unloads, so no new performance timeline begins. A user might click through ten "pages" while every Core Web Vital stays pinned to the entry URL.

The result is well known to anyone who has audited an SPA: the landing route looks fast, and the slow interactions deeper in the app never show up in the data.

How Chrome detects a soft navigation

Chrome's heuristic requires three things to happen in order before it records a soft navigation:

  1. The navigation is initiated by a user interaction — a click or a key press.
  2. The URL is modified by the History API or the Navigation API.
  3. A DOM modification follows the interaction, changing a previously existing DOM element.

This sequence is deliberately strict. A background pushState for analytics, an auto-advancing carousel, or a URL change with no interaction will not qualify. That precision matters: it means a soft navigation maps to something a human actually did, not to incidental script activity.

The API surface

Soft navigations surface through the standard PerformanceObserver using a dedicated entry type. You opt in per observer:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(entry.name, entry.startTime);
  }
}).observe({ type: "soft-navigation", buffered: true });

More importantly, other performance entries gain a navigationId. LCP, layout-shift, and event-timing entries emitted after a soft navigation carry the new ID, so you can re-attribute each Core Web Vital to the route the user was actually on:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // entry.navigationId ties this LCP to a specific soft navigation
    send({ metric: "LCP", value: entry.startTime, navId: entry.navigationId });
  }
}).observe({ type: "largest-contentful-paint", buffered: true });

Today this lives behind chrome://flags/#soft-navigation-heuristics and an origin trial, with limited CrUX exposure. It is not yet a default in stable Chrome, so treat field numbers as directional, not authoritative. The web-vitals library exposes the same data through its reportSoftNavs option, which is the practical way to wire it up without writing observers by hand.

Why this belongs in privacy-first analytics

Soft navigation data is pure timing. It contains no identifier, no cookie, and nothing that survives the page session — a duration, a coarse element type, a route path. That is exactly the kind of signal a cookie-free tool can collect without touching consent machinery.

The tracker already hooks history.pushState to count SPA route changes. Soft navigations refine that same hook: instead of asking only did the route change, you can ask was this an interaction-driven navigation, and how did it perform. The performance entries ride alongside the existing pageview beacon to /collect, adding field measurement without adding weight to the under-2 KB script or breaking the daily-hash identity model.

The trap to avoid is treating soft navigations as a reason to collect more. Some RUM vendors will use the new attribution to stitch per-user navigation journeys across a session — exactly the surveillance pattern cookie-free analytics exists to reject. The metric is valuable precisely because it can stay aggregate: median INP per route, LCP distribution per route, no path back to a person.

Soft navigations make the deepest, slowest parts of your app measurable for the first time. Collect the timing, drop everything else, and you get the performance picture without the profile.