Using the Soft Navigations API

The Soft Navigations API is Chrome’s attempt to give single-page applications the metrics a multi-page site gets for free: a per-route-change LCP, and interaction attribution scoped to the screen the user was actually on. It is experimental, heuristic and Chromium-only, which makes it a cross-check rather than a foundation. This guide sits under Soft Navigations & SPA Metrics and covers how to enable and observe it, what its detection rules actually require, and how to use its output alongside the router-based measurement you already trust.

Prerequisites

  • A Chromium browser with the feature enabled — either behind the experimental flag, or via an origin trial token if one is active for the version you target.
  • A router-based route-change measurement already in place, so you have something to compare against.
  • A beacon field for the detection source, because you will be storing two versions of the same idea.

How to use it

Step 1 — Detect it properly

The entry type only exists where the feature is enabled, so a support check is a one-liner and everything else must be guarded by it.

const SOFT_NAV_SUPPORTED =
  typeof PerformanceObserver !== 'undefined' &&
  PerformanceObserver.supportedEntryTypes?.includes('soft-navigation');

if (!SOFT_NAV_SUPPORTED) {
  // Nothing to do — the router measurement is the only source on this browser.
}

Never feature-detect by user agent or by version. The flag state varies per profile, per enterprise policy and per origin trial, and a UA check will be wrong in both directions.

Step 2 — Observe soft navigations

// Each entry marks a route change the browser believes happened.
if (SOFT_NAV_SUPPORTED) {
  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      queueBeacon({
        metric: 'soft_navigation',
        value: Math.round(entry.startTime),
        // name is the URL after the navigation; navigationId ties later
        // entries (LCP, interactions) back to this route change.
        href: entry.name,
        navigationId: entry.navigationId,
        source: 'browser',
      });
    }
  }).observe({ type: 'soft-navigation', buffered: true });
}

Step 3 — Understand what triggers detection

The browser is inferring a navigation, and its heuristic requires all three of the following in sequence:

Requirement What it means in practice
A user interaction A click or keypress — a programmatic pushState alone does not qualify
A URL change Via History API or the Navigation API, within a short window of the interaction
A DOM modification that paints The new screen has to actually render something

That third requirement is where router-based and browser-based counts diverge most. A route change that swaps a small region, or that shows a skeleton indistinguishable from the previous screen, may not register as a soft navigation at all. A route change triggered by a timer or a redirect with no preceding interaction will never register.

Agreement between the router and the browser heuristicThe heuristic tracks real screen changes well and ignores everything else. Where the two disagree, the disagreement usually tells you the transition does not look like a navigation to a user either.Agreement between the router and the browser heuristicRoute changes recorded by each source, Chromium sessions onlyLink click to a full screen94%Tab switch within a page41%58%Programmatic redirect8%92%Modal that changes the URL22%61%17%Both agreeRouter onlyBrowser only
The heuristic tracks real screen changes well and ignores everything else. Where the two disagree, the disagreement usually tells you the transition does not look like a navigation to a user either.

Step 4 — Read the per-navigation metrics

The point of the API is not the entries themselves; it is that other performance entries become attributable to a soft navigation. Where the feature is enabled, LCP and interaction entries carry a navigationId that ties them to the route change they belong to.

// Per-soft-navigation LCP: the metric SPAs normally cannot report at all.
if (SOFT_NAV_SUPPORTED) {
  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (!entry.navigationId) continue;         // belongs to the initial load
      queueBeacon({
        metric: 'soft_nav_lcp',
        value: Math.round(entry.startTime),
        navigationId: entry.navigationId,
        element: entry.element?.tagName?.toLowerCase() ?? null,
        source: 'browser',
      });
    }
  }).observe({ type: 'largest-contentful-paint', buffered: true });
}

Store these under distinct metric names. A soft-navigation LCP is not the document LCP, it is not what the origin is assessed on, and mixing the two produces an aggregate that matches nothing.

Step 5 — Reconcile it against your router measurement

Run both and compare. Three outcomes, each of which teaches you something.

They agree within a few percent. Your route transitions look like navigations to the browser, which means they probably look like navigations to users. Use the browser value for LCP, which your router cannot produce, and your own value for everything else.

The router sees many the browser does not. Some of your route changes do not paint meaningfully. Worth investigating on its own terms: a transition the browser cannot detect is often one the user does not perceive either.

The browser sees some the router does not. Usually modals or filters that change the URL. Decide whether those should count as route changes in your own model, and make the two consistent.

Step 6 — Keep the fallback authoritative

Because the feature is experimental and single-engine, your router measurement stays the primary series and the browser data is an enrichment. Concretely: dashboards, budgets and alerts run on the router number; the browser number is a supplementary column that will be missing for most of your traffic.

// One beacon shape, two possible sources, never silently merged.
queueBeacon({
  metric: 'route_change',
  value: routerDuration,
  route: matchedPattern,
  source: 'router',                       // always present
  browserDetected: SOFT_NAV_SUPPORTED && sawSoftNavEntry,   // enrichment
});

What the data is worth once you have it

Two analyses become possible with per-navigation attribution that were not possible before, and both are worth the setup on a Chromium-heavy application.

Per-screen LCP for a single-page application. The metric users care about — how long until this screen showed its main content — has no browser-provided equivalent after the first load. Where the API is enabled, it exists, and it can be compared against the same screen reached by a hard load. A large gap between the two means the client-side path is doing work the server-rendered path does not.

Interaction attribution scoped to a screen. Interactions carrying a navigationId can be grouped by the route change they followed, which turns the document-scoped INP problem into a per-screen ranking without any of the accumulator bookkeeping described in the parent guide. Where available, it is the cleaner measurement; where unavailable, the manual approach is the fallback.

Neither analysis can be run on your whole population. Both are useful anyway: a per-screen ranking derived from a third of your traffic still tells you which screen to fix.

Per-screen LCP, only available where the API is enabledNone of these numbers exist without the API — after the first load the browser stops emitting LCP candidates entirely. The initial load is the only row a standard reporter would have seen.Per-screen LCP, only available where the API is enabledSoft-navigation LCP by destination, Chromium sessions with the flag/ (initial load)2,140 ms/products/:id1,180 ms/search1,620 ms/checkout2,380 ms0 ms625 ms1,250 ms1,875 ms2,500 ms
None of these numbers exist without the API — after the first load the browser stops emitting LCP candidates entirely. The initial load is the only row a standard reporter would have seen.

Verifying it works

  1. Entries appear at all. If none arrive, the feature is not enabled in that browser — check the flag or the origin trial token rather than your code.
  2. navigationId links entries together. LCP entries with a navigation id should follow a soft-navigation entry with the same id.
  3. Counts are in the same order of magnitude as your router-based count on the same sessions. An order of magnitude apart means one of the two is measuring something else.
  4. No browser-derived value has leaked into the assessed metric series. Query for metric = 'LCP' and confirm every row came from the document, not from a soft navigation.
What the browser requires before it calls it a navigationMiss any one and no entry is emitted. A programmatic redirect fails the first requirement; a subtle in-place update fails the third.What the browser requires before it calls it a navigationAll three, in this order, within a short windowUser interactionclick or keypressURL changeHistory or Navigation APIDOM modificationthat actually paintssoft-navigation entryemitted
Miss any one and no entry is emitted. A programmatic redirect fails the first requirement; a subtle in-place update fails the third.

Edge cases and gotchas

  • It is Chromium-only and experimental. Behaviour has changed between releases, including the detection heuristic itself. Pin nothing to it.
  • buffered: true may not replay entries from before the observer was created in all versions. Register the observer as early as possible.
  • Programmatic navigations are invisible unless preceded by a user interaction, which excludes redirects, timers and deep-link handling.
  • The URL change must be close in time to the interaction. A route change that waits for a slow loader before calling pushState may fall outside the detection window.
  • Origin trial tokens expire. A silently expired token looks exactly like a browser that never supported the feature.
  • Do not report these values to any external vitals collector expecting standard metrics. They will be interpreted as document-level values and will be wrong.

FAQ

How do I test it locally?

Enable the experimental web platform features flag in a Chromium build, open a page that performs a real route change after a click, and confirm entries arrive in the console. Testing without the flag produces a silent no-op that is easy to mistake for a code bug.

Does it affect the metrics reported to search systems?

No. The assessed metrics remain document-scoped, and the soft-navigation entries are additional data available to your own instrumentation only. Nothing you do here changes what CrUX records.

Should I replace my router instrumentation with this?

No. It is Chromium-only, experimental, and heuristic. Keep the router measurement as the authoritative series and treat the browser data as an enrichment that covers part of your traffic.

Does it fix INP for single-page applications?

It makes interactions attributable to a soft navigation, which is most of what teams want. It does not change how INP itself is computed or reported for the document, and it does not reset the assessed value.

Why do the two counts disagree?

Because the browser requires an interaction, a URL change and a painting DOM modification in sequence. Anything missing one of those — a programmatic redirect, a subtle in-place update — is a route change to your router and not to the browser.

Will this become a standard?

It is under active development and the shape has already changed more than once. Build so that its absence costs you nothing, and its presence is a bonus column.