If you maintain a React, Vue, Svelte, or Angular single-page app, you have lived with a quiet performance lie for years: the browser sees one navigation — the first one — and everything after that is just an interaction. Lighthouse scores the initial paint. CrUX reports the initial paint. Your real users, meanwhile, click through ten "pages" without ever loading a new document, and none of those route transitions show up in Largest Contentful Paint or any of the other Core Web Vitals that Google ranks you on.
Chrome is finally about to fix that. The Soft Navigations API entered its final origin trial in Chrome 147 in March 2026, running through Chrome 149, and the Chrome team is aiming to ship it later this year. It's the first time the browser itself defines what counts as a navigation inside a SPA, and the first time real LCP, CLS, and INP per route are observable through standard PerformanceObserver entries.
Why SPAs broke Core Web Vitals
The Core Web Vitals initiative was explicitly designed to be technology-agnostic: three user-centric metrics meant to measure perceived experience regardless of how a site is built. In practice, the architecture has always mattered.
The reason is structural. A traditional multi-page site emits a fresh navigation performance entry every time the browser commits a new document. LCP, CLS, INP, and TTFB all hang off that entry, and they all reset when a new one arrives. A SPA does none of that. It calls history.pushState, swaps the DOM with JavaScript, and the browser's notion of "the page" never changes. LCP gets finalized on the very first interaction and is never measured again. CLS and INP keep accumulating across every route the user visits, so they're effectively a session metric instead of a page metric.
Frameworks have papered over this with their own heuristics. Next.js, Remix, and the various Vue meta-frameworks all hook into their router events to call something like web-vitals's reporters at route transitions. The problem is that every framework picks a different definition of when a navigation has happened, and analytics vendors can't reconcile them. None of it is comparable across sites, which is why CrUX has never used any of it.
What a soft navigation actually is
Chrome's definition is deliberately minimal. A soft navigation requires three things to happen, in this order:
- A user interaction occurs.
- The URL changes (via
pushStateor, as of this trial,replaceState). - The interaction results in a visible paint.
If any of those is missing, it doesn't count. A pushState triggered by an analytics ping with no DOM change doesn't fire. A render that doesn't change the URL doesn't fire. An animation that updates the same URL on a timer doesn't fire. The browser is the arbiter, which is the whole point — it removes the framework from the decision.
The neat consequence is that existing SPAs need no code changes to be measured. As long as the framework you use eventually calls pushState and renders something, Chrome will see the soft navigation.
The new performance entries
The API ships two new PerformanceObserver entry types. The first is soft-navigation, emitted when all three conditions above are met. It carries an interactionId for the click that started the navigation, a unique navigationId, the new URL in name, and paint timings you can treat as the soft navigation's First Contentful Paint.
The second is interaction-contentful-paint, which fires after any interaction that produces a contentful paint — not just ones that resolve into soft navigations. This is the key change in the Chrome 147 trial: previously, interaction-contentful-paint only fired for confirmed soft navigations, which made it hard to compute LCP cleanly because paints often arrive before the URL changes. Decoupling them lets you observe all post-interaction paints and then attribute them later.
To make that attribution easier, the soft-navigation entry now includes a largestInteractionContentfulPaint field that points at the largest paint observed up to that point, regardless of whether it arrived before or after the URL update. You no longer have to buffer and look backward.
The existing entries — largest-contentful-paint, event, layout-shift, and now interaction-contentful-paint — all gain a navigationId so you can slice them across soft navigation boundaries without playing games with timestamps.
Observing it in code
Feature detection is straightforward, with one caveat about origin trial activation:
const supported =
PerformanceObserver.supportedEntryTypes.includes('soft-navigation') ||
'SoftNavigationEntry' in window;
if (supported) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('Soft nav to', entry.name, {
navigationId: entry.navigationId,
interactionId: entry.interactionId,
startTime: entry.startTime,
largestPaint: entry.largestInteractionContentfulPaint,
});
}
});
observer.observe({ type: 'soft-navigation', buffered: true });
}
The Chrome team is explicit that supportedEntryTypes is frozen on first read, so if you activate the trial via a meta tag injected after that read, you should fall back to the SoftNavigationEntry in window check.
Mapping interaction-contentful-paint entries to the right URL requires using interactionId, not navigationId, because the paint may arrive before the soft navigation is confirmed and therefore still carry the old navigationId. The official docs walk through this in detail — it's the trickiest piece of the API to get right, and it's the reason the team is recommending you use the web-vitals library rather than rolling your own observers.
Use the web-vitals library
Google maintains an experimental soft-navs branch of web-vitals that handles all of the attribution rules for you. The pattern is to register your existing reporters and add a parallel set with reportSoftNavs: true:
import {
onTTFB,
onFCP,
onLCP,
onCLS,
onINP,
} from 'https://unpkg.com/web-vitals@soft-navs/dist/web-vitals.js?module';
// Existing reporters for the initial hard navigation
onLCP(send);
onCLS(send);
onINP(send);
// New reporters that fire per soft navigation as well
onLCP(send, { reportSoftNavs: true });
onCLS(send, { reportSoftNavs: true });
onINP(send, { reportSoftNavs: true });
The library reports TTFB as 0 for soft navigations, mirroring its treatment of bfcache restores. LCP is computed from interaction-contentful-paint entries whose interactionId matches the soft navigation, and CLS and INP are sliced between navigation boundaries.
What this means for your monitoring
A few practical implications worth flagging before you wire any of this into production telemetry.
Your dashboards will start reporting more LCP samples per session. If you have an SLO based on LCP-good-page-views, those numbers will move when soft navigation data starts flowing in, because routes deep inside the app frequently have very different LCP characteristics than the marketing pages most users land on first. The Chrome team recommends including the navigationType on every record so you can segment hard versus soft.
CrUX will not change yet. The Chrome team has been explicit that the origin trial is to evaluate the API, not the reporting pipeline. How soft navigations land in CrUX and PageSpeed Insights is a separate decision that will be made after launch. So for now, your CrUX-driven SEO score still reflects only the initial hard navigation, even if your RUM tool is reporting per-route data.
DevTools already shows soft navigation markers in the Performance panel as of Chrome 145, with no flag required. If you want to spot-check whether the heuristic agrees with your intuition for what counts as a navigation in your app, record a trace and look for the markers with the * suffix. This is the quickest way to validate the detector against your specific router setup before you wire up RUM.
Actionable takeaways
If you ship a SPA, three things are worth doing now, ahead of the launch.
First, enable the trial in Chrome locally with chrome://flags/#soft-navigation-heuristics and walk through your app's main routes while watching the Performance panel. Confirm the soft navigation markers appear where you expect, and file issues on the WICG repository if they don't — the Chrome team is explicitly asking for feedback during this trial.
Second, add a parallel set of web-vitals reporters with reportSoftNavs: true, but keep your existing hard-navigation reporters. Tag every metric with a navigationType field. You'll have a baseline ready when the API ships and a way to compare the two methodologies without losing historical data.
Third, plan for the metrics conversation. Most SPA teams have been quietly tolerating CrUX scores that don't reflect their actual experience. Once per-route LCP becomes measurable and eventually counted, the deep parts of the app — search results, item detail pages, dashboards — will be visible in a way they haven't been before. Some of those will surprise you. Better to discover that on your own RUM before it shows up in PageSpeed Insights.
The Soft Navigations API isn't a magic performance fix. What it is, finally, is an honest measurement.