Skip to main content

If your product is a single-page application, you have spent the last five years measuring its performance with instruments that were never designed for it. Core Web Vitals were built around a document model: a user clicks a link, the browser unloads one page and loads another, and the metrics reset with a clean time origin. SPAs broke that assumption on purpose. Route transitions happen in JavaScript, the document never unloads, and the browser has no idea that the user just moved from a dashboard to a settings screen.

The result has been a well-known gap in the industry. Largest Contentful Paint gets measured exactly once, on the initial hard load, and then never again. Cumulative Layout Shift and Interaction to Next Paint keep accumulating across an entire session, so a user who spends forty minutes in your app produces numbers that describe nothing in particular. Chrome 151, which began rolling out at the end of July 2026, is the first stable release that closes this gap.

TL;DR

  • Chrome 151 ships the Soft Navigations API unflagged, adding two new PerformanceEntry types: soft-navigation and interaction-contentful-paint.
  • Chrome now detects SPA route changes heuristically, using three criteria: a user-initiated interaction, a visible URL change that creates a history entry, and a visible paint.
  • Every largest-contentful-paint, interaction-contentful-paint, event-timing and layout-shift entry now carries a navigation identifier, so metrics can be attributed to the right route without timestamp matching.
  • The web-vitals library from v6.0.0 supports this through a reportSoftNavs option, which handles URL attribution and metric resets for you.
  • Critically, this is real user monitoring data only. Google has no announced plans to include soft navigation data in the Chrome User Experience Report, so there is no immediate SEO or ranking implication.

Why SPAs were invisible to Core Web Vitals

The problem was never that SPAs are slow. Plenty of them are extremely fast. The problem was that the performance timeline had a single time origin, set at the hard navigation, and every metric was expressed relative to it.

That produces three specific failures. LCP fires once and stops, so the loading experience of every route after the first is unmeasured. INP is reported as a single value across the whole session lifetime, which means one pathological interaction on an obscure screen poisons the number for everything else. CLS accumulates without bound, so long-lived applications look progressively worse the longer someone uses them, regardless of whether anything actually shifted during a given task.

Teams worked around this with custom instrumentation: manual marks, route-change listeners wired into the router, bespoke definitions of “the page is ready now”. That works, but it is not comparable between applications, it drifts as the router changes, and it requires someone to maintain it. Every framework solved the same problem slightly differently, which is exactly the kind of fragmentation a web standard exists to eliminate.

What Chrome 151 actually ships

Two things, and the separation between them matters.

The first is soft navigation detection. Chrome emits a soft-navigation entry when it believes a route change has occurred. The entry carries the new URL in its name, plus a navigationId and the interactionId of the interaction that triggered it. This establishes a new time origin for attribution purposes.

The second is Interaction to Contentful Paint, emitted as an interaction-contentful-paint entry. This measures new contentful paints within DOM regions modified by a user interaction. It is the piece that gives you an LCP-equivalent for a route that was rendered client-side.

Earlier iterations of this proposal, which went through origin trials from Chrome 139 and a final trial in Chrome 147, coupled detection directly to an automatic LCP reset. Developer feedback pushed the Chrome team to decouple the two. That is a better design: contentful paint measurement after an interaction is useful beyond SPA routing, and detection heuristics can evolve without silently changing what LCP means.

Alongside both, existing entry types gained a navigation identifier. Previously, associating a layout shift with the route it occurred on meant matching timestamps by hand and hoping you got the boundaries right. Now the association is explicit.

How Chrome decides something is a navigation

The heuristic requires all three of the following:

  1. A user-initiated interaction, not a background update or a timer.
  2. A visible URL change that creates a new history entry.
  3. A visible paint resulting from that interaction.

The exclusions are deliberate. Programmatic updates that no user triggered are not navigations. Neither are replaceState-only changes, which applications routinely use for filter state, pagination and scroll restoration rather than genuine route transitions.

This is a heuristic, not a declaration, and that is a design decision worth understanding. Chrome chose canonical detection over developer-defined markers so that the definition is consistent regardless of which framework you use. The trade-off is that you will encounter both false negatives, where a genuine route change is missed, and false positives, where an in-page update looks like a navigation. If your application has unusual routing patterns, verify the detection before you trust the numbers.

Wiring it up

The raw API is a standard PerformanceObserver:

if ('SoftNavigationEntry' in window) {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      console.log(entry.name, entry.navigationId, entry.startTime);
    }
  });
  observer.observe({ type: 'soft-navigation', buffered: true });
}

Two implementation details cause most of the early mistakes. First, all timings are still reported relative to the initial hard navigation, so you must subtract the soft navigation’s startTime to get a meaningful per-route figure. Second, when mapping interaction-contentful-paint entries back to a route, use interactionId rather than navigationId, because some paint entries are emitted before the URL update lands.

Most teams should not hand-roll this. The web-vitals library handles the attribution and reset logic from v6.0.0:

import { onLCP, onINP, onCLS } from 'web-vitals';

onLCP(sendToAnalytics, { reportSoftNavs: true });
onINP(sendToAnalytics, { reportSoftNavs: true });
onCLS(sendToAnalytics, { reportSoftNavs: true });

That single option gives you per-route metrics with correct URL attribution and finalises the previous route’s numbers when a new navigation is detected.

What this does not change

Being precise here matters, because the SEO industry has a habit of over-reading this kind of release.

There is no ranking impact today. Soft navigation data is not included in the Chrome User Experience Report, and Google has announced no plans to add it. Your Search Console Core Web Vitals report will continue to reflect hard navigations only. Anyone telling you that Chrome 151 changes your SPA’s SEO position is guessing.

It is Chrome-only. The specification is still a draft at the W3C and the API shape has already changed twice across origin trials. Safari and Firefox have not shipped equivalents. Treat this as a real user monitoring signal from a majority of your traffic, not a complete picture.

It measures, it does not fix. An SPA that ships 900KB of JavaScript before it can render a route will now produce accurate evidence that it does so. That is genuinely valuable, but the remediation work is the same architectural work it always was.

What to do about it this quarter

For teams running a production SPA, the practical sequence is straightforward.

Start by upgrading web-vitals and enabling reportSoftNavs in a staging environment. Instrument first, decide later. Then validate detection against your actual router: walk through your ten most common user journeys and confirm that Chrome’s count of soft navigations matches your count of route changes. Discrepancies here are the single most likely source of misleading data.

Next, establish a baseline before you optimise anything. You are about to see per-route numbers for the first time, and the initial reaction to them is almost always wrong. Give yourself a fortnight of real traffic before drawing conclusions.

Finally, update your analytics schema. Per-route metrics are only useful if your dashboards can group by route rather than by session, and most existing SPA analytics setups cannot. This is usually the part that takes longest, and it is worth budgeting for properly rather than bolting on.

The wider point

The interesting thing about this release is not the API itself. It is what it signals about where the web platform is heading: the standards process is slowly catching up to the architectural choices that application teams made a decade ago, and doing so with heuristics rather than mandates.

For teams that have been quietly guessing at their SPA’s real-world performance, this is the moment that guessing becomes optional. The uncomfortable part is that accurate data usually reveals problems that estimates concealed. That is the point.

At REPTILEHAUS we build and maintain production applications where performance is a business metric rather than a vanity one, covering frontend architecture, observability instrumentation and the DevOps work that keeps both honest. If you are running an SPA and you are not confident that your performance data reflects what your users actually experience, get in touch and we will help you find out.


📷 Photo by Chris Liverani on Unsplash