Measuring Core Web Vitals in SPAs: The 2026 Soft Navigations API Guide

SPAs break LCP, INP, and CLS measurement — only the landing page gets real data. Chrome 147's Soft Navigations API finally fixes per-route Core Web Vitals tracking. A practical 2026 guide with code for PerformanceObserver, the web-vitals soft-navs build, and a production-safe hybrid strategy.

Here's the uncomfortable truth: if you run a single-page application, your Core Web Vitals report is almost certainly lying to you. Largest Contentful Paint (LCP) only fires on the first hard navigation. Cumulative Layout Shift (CLS) and Interaction to Next Paint (INP) keep accumulating across every subsequent route change — and they all get attributed to whichever URL the user originally landed on.

The result? A checkout page with a 900ms INP looks perfectly fine in CrUX (because the landing page was fast), and your "/" route gets blamed for shifts that actually happened three routes deep. I've watched teams spend a full sprint optimizing a homepage that wasn't broken, while the real culprit — a sluggish filter widget on a product list — sat untouched.

That gap is finally closing in 2026. Chrome 147 is running the last origin trial for the Soft Navigations API, with a stable launch expected later this year. So, let's dig in. This guide shows you exactly how to measure LCP, INP, and CLS per route in a SPA today — both the experimental API and a production-safe fallback you can ship right now.

Why Core Web Vitals Break in SPAs

Every Core Web Vitals metric is scoped to a single browser navigation. And the browser's idea of a "navigation" is pretty strict: a full document load, a request to the server, an HTML response, a brand-new document. SPAs deliberately avoid all of that. A click on a <Link> in React Router (or a Next.js <Link>) just triggers history.pushState(), updates the URL, and swaps the view via JavaScript. As far as the browser is concerned, you're still on the same page you loaded an hour ago.

Concretely, here's what that breaks:

  • LCP is finalized at the first user interaction after the initial load. Every route after the landing page gets no LCP value at all.
  • CLS is a session-cumulative score. Layout shifts on route 5 inflate the score reported against route 1.
  • INP reports the worst interaction across the entire session, attributed to the URL Chrome thinks you're on — which is usually the landing page.
  • TTFB is basically meaningless for in-app navigations because there's no server round trip.

This is a big part of why 43% of sites still fail INP in 2026. Heavy SPAs concentrate slow interactions on internal routes (checkout, filters, dashboards), but the metric blames the wrong URL — so teams end up optimizing the wrong page.

What the Soft Navigations API Actually Does

The Soft Navigations API teaches the browser to recognize SPA route changes as real navigations. Chrome uses a heuristic (no framework opt-in required) to detect when all three of these things happen close together:

  1. A user interaction (click, key press, tap) fires.
  2. That interaction's task chain calls history.pushState() or history.replaceState().
  3. The DOM changes and the browser produces a paint.

When the heuristic matches, Chrome emits a new soft-navigation performance entry with a unique navigationId and the new URL. From that point on, LCP, CLS, and INP can be sliced by navigationId — each soft navigation gets its own fresh metric window, just like a full page load.

The API also introduces a companion entry type, interaction-contentful-paint, which acts like LCP but for soft navigations. It captures the largest paint that happens after the user interaction that triggered the route change.

Browser Support and 2026 Status

As of May 2026:

  • Chrome 147 ships the final origin trial. Production sites can enroll a token and ship the API to all Chrome users.
  • Developers can enable the feature locally without a token via chrome://flags/#soft-navigation-heuristics.
  • The Chrome DevTools Performance panel has shown soft navigations in traces since Chrome 145, even without the flag.
  • Firefox and Safari don't support the API yet. Plan for a Chrome-only rollout — and a fallback for everyone else.
  • CrUX and PageSpeed Insights don't yet incorporate soft-navigation data. So the metrics you collect today are useful for your own RUM dashboards, but not for Search Console. Yet.

Detecting Soft Navigations with PerformanceObserver

The raw API is pretty straightforward. You register a PerformanceObserver for the soft-navigation type and reset your metric state every time an entry arrives.

// Detect soft navigations in Chrome 147+
if (PerformanceObserver.supportedEntryTypes?.includes('soft-navigation')) {
  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      console.log('Soft navigation to', entry.name, {
        navigationId: entry.navigationId,
        startTime: entry.startTime,
      });
      onRouteChange(entry);
    }
  }).observe({ type: 'soft-navigation', buffered: true });
}

To see LCP-style data for soft navigations, you have to opt in with the includeSoftNavigationObservations flag. It's an explicit opt-in so that existing observers (which were written before soft navigations existed) don't suddenly start seeing post-load LCP candidates.

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // entry.navigationId tells you which soft navigation this belongs to
    console.log('LCP candidate:', entry.startTime, entry.element, entry.navigationId);
  }
}).observe({
  type: 'largest-contentful-paint',
  buffered: true,
  includeSoftNavigationObservations: true,
});

// And the new interaction-contentful-paint entry type:
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('ICP:', entry.startTime, entry);
  }
}).observe({
  type: 'interaction-contentful-paint',
  buffered: true,
});

Using the Experimental web-vitals Soft-Nav Build

Honestly, you almost never want to wire up PerformanceObserver by hand for INP. The spec has enough subtle edge cases that a single off-by-one in your code will produce inflated numbers (ask me how I know). Google ships an experimental build of the web-vitals library that handles soft navigations correctly, and it's the right starting point for most teams.

npm install web-vitals@soft-navs

Then in your app entry point:

import { onLCP, onINP, onCLS, onFCP } from 'web-vitals/soft-navs';

function send(metric) {
  // metric.navigationType will be 'navigate', 'reload', 'back-forward',
  // 'back-forward-cache', 'prerender', or 'soft-navigation'
  navigator.sendBeacon('/rum', JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    id: metric.id,
    navigationType: metric.navigationType,
    url: location.pathname, // capture the URL at report time, not at page load
  }));
}

// reportAllChanges: true is required to capture metrics for every soft nav,
// not just the final one before the page unloads.
onLCP(send, { reportAllChanges: true });
onINP(send, { reportAllChanges: true });
onCLS(send, { reportAllChanges: true });
onFCP(send, { reportAllChanges: true });

The library resets LCP, CLS, and INP to fresh windows on every soft navigation, finalizes the previous navigation's metrics, and tags each report with navigationType: 'soft-navigation' so you can segment in your dashboard. TTFB is reported as 0 for soft navigations, which is consistent with how it's handled for back/forward cache restores.

Capturing the Right URL

This trips up almost every team on the first try — I'd bet most readers will hit it too. By default, location.pathname is read once when the metric reporter initializes, so every report ends up tagged with the landing URL. The fix is to read location.pathname inside the send function, at the moment the metric finalizes, not at module load.

For analytics dashboards, you'll also want to group by route template, not raw URL. Otherwise /product/123 and /product/456 show up as separate rows (and your top-10 INP list becomes a list of product IDs, which is useless). Capture both:

function send(metric) {
  navigator.sendBeacon('/rum', JSON.stringify({
    ...metric,
    url: location.pathname,
    route: matchRouteTemplate(location.pathname), // e.g. "/product/:id"
    navigationType: metric.navigationType,
  }));
}

Per-Framework Integration

Next.js (App Router)

Next.js exposes useReportWebVitals, but it doesn't yet emit soft-nav events. Until it does, run the soft-navs build of web-vitals in a top-level client component and skip the built-in hook for LCP/CLS/INP:

'use client';
import { useEffect } from 'react';
import { onLCP, onINP, onCLS } from 'web-vitals/soft-navs';

export function VitalsReporter() {
  useEffect(() => {
    onLCP(report, { reportAllChanges: true });
    onINP(report, { reportAllChanges: true });
    onCLS(report, { reportAllChanges: true });
  }, []);
  return null;
}

Mount <VitalsReporter /> once in your root layout. The web-vitals library handles deduplication internally — you don't need to remount it per route.

React Router / Vue Router

Good news: no framework-specific code is needed. The Soft Navigations API observes pushState and replaceState calls regardless of who makes them, so any router that uses the History API just works. Verify by clicking a link in DevTools' Performance panel — you should see a "Soft Navigation" marker on the timeline.

Frameworks That Don't Call pushState

A few frameworks update the URL with location.hash changes, or with history.replaceState() outside the click handler's task chain. The heuristic won't catch those. And if your framework batches state updates and only calls pushState in a microtask after the interaction's macrotask has ended, soft navigation won't be detected either. The fix is usually to call pushState synchronously in the click handler.

The Production-Safe Hybrid Strategy

Here's the part most articles skip. You can't rely on soft navigations alone in 2026 — Firefox and Safari users, older Chrome users, and CrUX itself don't see them. The pragmatic setup is to collect both:

  1. Use the standard web-vitals library to report metrics the way CrUX sees them. This keeps your Search Console data aligned with what you collect.
  2. In parallel, run the soft-navs build to produce per-route metrics for your internal RUM dashboard. Tag those reports with a source: 'soft-nav' field so they don't pollute your CrUX-aligned data.
import * as stable from 'web-vitals';
import * as softNavs from 'web-vitals/soft-navs';

stable.onINP((m) => report({ ...m, source: 'stable' }));
softNavs.onINP((m) => report({ ...m, source: 'soft-navs' }), {
  reportAllChanges: true,
});

Send both to your analytics backend, then build two dashboards: one matches Search Console (use the stable source), and one shows per-route performance so engineers can find the actually-slow page (use the soft-navs source). When the API ships stable and CrUX adopts it, you collapse to one dashboard. Until then, two is the price of getting this right.

Debugging Soft Navigations in DevTools

Chrome 145+ shows soft navigations in the Performance panel timeline as a labeled marker. The Performance Insights panel will flag a soft navigation and list the LCP, CLS, and INP attributed to that specific navigation. To enable this in Chrome before 147, set chrome://flags/#soft-navigation-heuristics to "Enabled" and restart.

If you record a trace and don't see a "Soft Navigation" marker where you expected one, the most common causes are:

  • The route change wasn't triggered by a user interaction (e.g., a redirect on mount).
  • The pushState call happened outside the interaction's task chain — usually because of a delayed promise or setTimeout.
  • No new paint happened (the new view rendered identical pixels).
  • The URL didn't actually change (you called replaceState with the same URL).

Per-Route INP: The Biggest Win

Of the three vitals, INP benefits the most from soft-navigation slicing. Before soft navs, a single slow interaction on a deep route was attributed to the landing page, swamping the signal you actually needed. With soft navs, you can finally answer the question: "Which routes have INP > 200ms at p75?"

Query your RUM data grouped by route and navigationType = 'soft-navigation', then sort by p75 INP descending. The top of that list is your prioritized work queue for INP optimization. Pair this with the Long Animation Frames (LoAF) API for attribution — LoAF entries also carry navigationId, so you can join them to the slow soft navigation that produced them. It's a workflow that genuinely changes how you triage performance work.

Common Pitfalls

  • Calling onINP twice. Each call registers a new PerformanceObserver. Two calls double your reports and can leak memory. Wrap the library in a singleton, or call it once at module top level.
  • Forgetting reportAllChanges: true. Without it, web-vitals only reports a metric once, at page unload. For SPAs where the page rarely unloads, you'll get one report after the user closes the tab — useless for per-route data.
  • Capturing the URL at the wrong time. Read location.pathname inside the send callback, not at metric-observer setup.
  • Comparing soft-nav metrics to CrUX. CrUX doesn't see soft navigations. Don't be surprised if your internal LCP looks worse than Search Console — you're now seeing internal routes that were previously invisible.
  • Optimizing without segmenting. Always group by navigationType. Soft-navigation LCP has a different distribution than hard-navigation LCP, and mixing them produces misleading p75 values.

FAQ

Does the Soft Navigations API affect my Google search ranking?

Not yet. CrUX — the dataset Google uses for the Page Experience signal — doesn't yet incorporate soft-navigation data. Your Search Console Core Web Vitals report still reflects only hard navigations. Once the API ships stable (expected later in 2026) and CrUX adopts it, internal routes in your SPA will start affecting your ranking signal. That's a strong reason to start measuring per-route INP and CLS now, before it suddenly matters for SEO.

Do I need to change my React or Vue code to support soft navigations?

In almost all cases, no. Chrome detects soft navigations by watching for the pattern "user interaction → pushState → DOM change → paint." Any router that uses history.pushState in response to a click — which is essentially every modern SPA router — produces soft-navigation entries automatically. The only changes you make are in your RUM reporting code, not your routing code.

What's the difference between the Soft Navigations API and just resetting metrics manually on route change?

Manual reset has been the workaround for years and is what most SPA RUM solutions did before 2025. The problem is that every framework defined "route change" differently, and the reset timing didn't line up with the user's perception of a new page — you'd often reset CLS before the new view had actually painted, missing real layout shifts. The Soft Navigations API standardizes the definition (interaction + URL change + paint) and provides a navigationId that future tools like CrUX can use, so your RUM data, DevTools, and field data all agree on what a "page" is.

Can I measure Core Web Vitals for soft navigations in Firefox or Safari?

Not via the Soft Navigations API — neither browser supports it as of May 2026. You can still measure per-route metrics in those browsers by resetting the web-vitals observers on your router's route-change event, but the results won't be directly comparable to Chrome's soft-navigation data because the trigger conditions are different. The hybrid strategy in this guide handles both cases by collecting standard metrics from all browsers and per-route metrics from Chrome.

What's the difference between interaction-contentful-paint and LCP?

They're conceptually the same — "what's the largest meaningful paint in this navigation" — but scoped differently. Regular LCP is observed from page load until the first user interaction. interaction-contentful-paint is observed from a user interaction (the one that triggered the soft navigation) until the next user interaction. For developers, the practical difference is which entry type you observe; if you use the web-vitals/soft-navs library, it picks the right one for you and reports both under the unified onLCP callback with the matching navigationType.

About the Author Editorial Team

Our team of expert writers and editors.