SevyDevy

Guide · Frontend

Core Web Vitals for Single Page Apps: Soft Navigations Explained

20 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • Core Web Vitals for single page apps can now be measured per route in Chrome and Edge 151 or later. Core Web Vitals are three scores for loading, responsiveness and layout stability.
  • A single page app swaps screens with JavaScript instead of loading new pages. Before Chrome 151, its loading score stopped at the first tap, so later screens had no loading score of their own.
  • Two new browser records do the work: a soft-navigation entry marks each detected screen change, and an interaction-contentful-paint entry times the biggest paint a tap caused.
  • As of 19 October 2026, Firefox and Safari do not report these entries. CrUX, Chrome's public dataset of real-user scores, has published experimental versions that include soft navigations since October 2026.
  • Beginners can stop after the hard vs soft navigation diagram. Engineers get what we ran in Chromium 153, the open-source core of Chrome: which taps and timers count, a 50-entry buffer trap, and why one slow tap hurts two routes.

What goes wrong when you measure a single page app like a normal website?

Measuring Core Web Vitals for single page apps the old way shows you only the first page load. Every screen a user opens after that is missing or mixed into the first one, even the slow ones.

A single page app, or SPA, loads one HTML page and then redraws the screen with JavaScript. Each screen has its own address, called a route, like /cart. Moving between routes inside the app does not fetch a fresh HTML page.

Picture a common setup. A food-ordering web app is built as a React SPA, and most customers open the home page, tap a restaurant card and land on its menu route. The setup is made up, but the problem is real.

Support tickets keep saying the menu takes ages to appear, about 140 a week (made up). The performance dashboard shows a healthy home page load of 1.9 seconds (made up). It has no row at all for the menu, cart or checkout.

The dashboard is not broken. Until Chrome 151 turned soft navigations on by default, Chrome timed route changes only for sites in opt-in trials, so the team's tools never saw the menu load.

Which Core Web Vitals words do you need first?

Core Web Vitals are three numbers that describe how a page feels to real visitors. Each has a target, checked at the 75th percentile of page loads, split by mobile and desktop.

The 75th percentile, or p75, is the value that three out of four visits match or beat. A page passes when all three numbers meet their targets at p75.

  • LCP, Largest Contentful Paint: how long until the biggest image or text block on screen is drawn. Good is 2.5 seconds or less, and poor is over 4 seconds.
  • INP, Interaction to Next Paint: how long the slowest tap, click or key press takes to show a response, ignoring rare outliers. Good is 200 milliseconds or less, and poor is over 500 milliseconds.
  • CLS, Cumulative Layout Shift: a score for how much the page jumps around unexpectedly. Good is 0.1 or less, and poor is over 0.25.

A hard navigation is a full page load, where the browser fetches new HTML. A soft navigation is a route change inside an SPA, where JavaScript swaps the content and updates the address bar.

Real user monitoring, or RUM, means measuring these numbers in your actual visitors' browsers and sending them to your own server. CrUX, the Chrome User Experience Report, is Chrome's public dataset of the same kind of numbers.

How do you measure Core Web Vitals for single page apps?

To measure Core Web Vitals for single page apps, listen for soft-navigation entries in Chrome and Edge 151 or later. Each entry marks a new route, so you close the old route's numbers and start fresh ones.

The recipe has four parts. Detect support, observe the new entries, restart the metrics per route, and send one report per route view to your server. Everything after this section shows each part working.

Think of a cricket scorer, who starts a fresh line in the scorebook when a new batter walks in. The route is the batter, the line is that route's LCP, INP and CLS, and the soft-navigation entry is the walk to the crease.

The mapping holds in one more place. The ball that gets a batter out is written on the outgoing batter's line, and the tap that changes route usually counts towards the old route's INP.

Where the picture breaks: a scorer never misses a new batter. Chrome's detection is a guess built from rules, so some route changes are missed and some non-routes are counted.

Why did single page apps report LCP only for the first page load?

The browser stops reporting new LCP candidates as soon as the user taps, scrolls or presses a key. In an SPA, a route change usually starts with a tap or click, so LCP was final before the second screen began.

INP and CLS had the opposite problem. They kept collecting over the whole visit, so a jumpy checkout screen was filed under the home page, where the visit began.

We checked the first half of that in Chromium 153. After a tap that painted a much bigger menu, the classic LCP observer received nothing new. Its last entry stayed at the home page's first paint, about 40 ms after load.

Hard vs soft navigation for one tap

Notice that the same tap only gets fresh metrics on the soft path when Chrome detects it; otherwise LCP stays frozen and INP and CLS keep piling up.

Diagram connections:

  • Customer taps a card to Full page load?
  • Full page load? to Server sends new HTML (Yes)
  • Server sends new HTML to Fresh LCP, INP and CLS
  • Full page load? to React swaps the view (No)
  • React swaps the view to Address bar changes
  • Address bar changes to Chrome 151 or later?
  • Chrome 151 or later? to soft-navigation entry (Yes)
  • Chrome 151 or later? to Nothing new recorded (No)
  • Customer taps a card: Restaurant card on the home route
  • Full page load?: Does the browser fetch new HTML?
  • Server sends new HTML: Hard navigation
  • Fresh LCP, INP and CLS: Normal page-load metrics
  • React swaps the view: JavaScript paints the menu
  • Address bar changes: Router pushes /restaurant/1042
  • Chrome 151 or later?: And did all three rules hold?
  • soft-navigation entry: Route gets its own metrics
  • Nothing new recorded: LCP frozen, INP and CLS pile up

Beginners can stop here. You now know the problem, the three scores, the difference between hard and soft navigations and the four-part recipe. The rest is for engineers who will build it.

What counts as a soft navigation in Chrome?

Chrome counts a soft navigation when three things happen together. A user action starts it, the URL changes, and that same action leads to something new being painted.

  • A user action: a click, tap or key press. Work the action schedules can still count; in our test, a 50 ms timer set inside the click handler did.
  • A same-document URL change through the History API, the browser feature routers use to change the URL without a reload. pushState adds a history entry and counts; replaceState swaps the current entry and counts since Chrome 147. Back and forward moves count too.
  • A contentful paint caused by that same action: new text or images actually appear on screen.

The browser can only guess which work belongs to which action. The Chrome documentation calls the rules a heuristic and warns of false positives and false negatives, so log the entries on your main routes first.

What we saw when we tried each case

The last column shows ICP entries, short for interaction-contentful-paint: Chrome's record of the biggest paint an interaction caused. The next section covers them.

Pause and predict before reading the table. A click starts a 50 ms timer, and only the timer changes the URL and paints. Does Chrome count a soft navigation?

Soft navigation detection, tested in Chromium 153 without a visible window (headless) on a small test SPA, 2 runs each
What the page didsoft-navigation entry?ICP entry?
Click, then pushState and paint the menuYes, navigationType pushYes
Click, then replaceState and paint the cartYes, navigationType replaceYes
Click a Back button that calls history.back()Yes, navigationType traverseYes
Browser back, triggered by the test driverYes, navigationType traverseYes
Click starts a 50 ms timer; the timer pushes and paintsYes, pushYes
A timer pushes and paints with no click at allNoNo
Click opens a filter drawer, URL unchangedNoYes
Click changes the URL but paints nothing newNoNo

The timer case counts because Chrome tracks work that a click schedules, which Chromium calls task attribution. A drawer that paints without a URL change gets an ICP entry but no soft navigation.

These results come from one test page in a headless Chromium 153 build packaged for servers, not a Chrome release. Chrome 155 was the stable version on 19 October 2026. The detection rules have changed often in Chromium's changelog, so re-run your own routes after each Chrome release.

What are the soft-navigation and interaction-contentful-paint entries?

They are two new kinds of performance entry, the small timing records a browser keeps about a page. Both are defined in the Soft Navigations draft specification of the WICG, a W3C community group.

A soft-navigation entry, whose interface is PerformanceSoftNavigation, appears once per detected route change. An interaction-contentful-paint entry, or ICP, appears whenever an interaction causes a contentful paint, route change or not.

Think of ICP as LCP for one interaction. Each ICP entry carries a normal LCP record in a field called largestContentfulPaint, and a later, bigger paint for the same tap produces a new ICP entry.

Fields on the new entries that matter for route metrics (Chromium 153)
FieldFound onWhat it tells you
namesoft-navigationThe first URL the tap moved to, such as one ending in /restaurant/1042; an in-app redirect after it does not change this
startTimeBoth entriesWhen the tap or key press began; it equalled the click event's start time in our test
navigationTypesoft-navigationpush, replace or traverse
interactionIdBoth entriesWhich interaction caused it; the same number appears on the browser's timing record for the click itself
getLargestInteractionContentfulPaint()soft-navigationThe largest ICP entry for this route so far, read at the moment you call it
largestContentfulPaintinteraction-contentful-paintThe LCP record for the biggest paint, with its element and time
navigationIdEvery performance entry, Chrome 151 or laterWhich hard or soft navigation an entry belongs to

The names changed late. Chromium's changelog says Chrome 150 renamed SoftNavigationEntry to PerformanceSoftNavigation and turned a largestInteractionContentfulPaint attribute into the getLargestInteractionContentfulPaint() method.

That rename matters for feature detection. The Chrome documentation page still offers checking for SoftNavigationEntry on window as one option, and in Chromium 153 that check returned false. Checking PerformanceObserver.supportedEntryTypes for soft-navigation returned true.

How do you observe soft navigations with PerformanceObserver?

A PerformanceObserver is a browser object that calls your function whenever new entries of a chosen type appear. Pass buffered: true, so you also get entries recorded before your script ran.

Here is a first version that sends one LCP report per route view. The naive alternative, reading location.pathname when a metric arrives, files late numbers under whatever route the user is on by then.

javascript
// One LCP beacon per route view
function routeOf(url) {
  return new URL(url).pathname.replace(/\/\d+/g, '/:id');
}
let view = { route: routeOf(location.href), kind: 'hard', lcp: 0 };
let softNav = null;

function sendView() {
  if (view.sent) return;
  if (softNav) {
    const icp = softNav.getLargestInteractionContentfulPaint();
    if (icp) view.lcp = Math.round(icp.largestContentfulPaint.startTime - softNav.startTime);
  }
  if (view.lcp) {
    navigator.sendBeacon('/rum', JSON.stringify(view));
    view.sent = true;
  }
}

// First (hard) load: the classic LCP observer
new PerformanceObserver(function (list) {
  if (!softNav) view.lcp = Math.round(list.getEntries().at(-1).startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });

// Every route change after that (Chrome and Edge 151 or later)
if (PerformanceObserver.supportedEntryTypes.includes('soft-navigation')) {
  new PerformanceObserver(function (list) {
    for (const entry of list.getEntries()) {
      sendView(); // close the route the user just left
      softNav = entry;
      view = { route: routeOf(entry.name), kind: 'soft',
               type: entry.navigationType, lcp: 0 };
    }
  }).observe({ type: 'soft-navigation', buffered: true });
}

document.addEventListener('visibilitychange', function () {
  if (document.visibilityState === 'hidden') sendView();
});

The includes check is feature detection, so Firefox and Safari skip the new code. Each new soft-navigation entry first calls sendView, which closes the route the user just left.

At send time, getLargestInteractionContentfulPaint returns the biggest paint for that route so far. Subtracting the soft navigation's startTime gives milliseconds since the tap, as the Chrome documentation recommends.

The routeOf helper turns /restaurant/1042 into /restaurant/:id, so all menus share one row. A beacon is a small fire-and-forget request that the browser still sends while the tab closes.

We ran this exact code in Chromium 153 with a menu photo that the test server held back for 600 ms. It sent three beacons, one per route view.

  • The home route, hard load: lcp about 40 ms.
  • The menu route, soft, type push: lcp about 615 ms, because the photo became the largest paint once it arrived.
  • The cart route, soft, type replace: lcp about 15 ms, sent when the tab was hidden.

Exact numbers vary by machine and run. In a few of our runs on a busy machine, the photo landed late and the menu read about 700 ms to 1.3 seconds.

How do you check it on your own app?

Open your app in Chrome 151 or later, click through two or three routes, then paste this into the DevTools console. It reads the buffered entries and prints one row per route change.

javascript
console.table(performance.getEntriesByType('soft-navigation').map(function (e) {
  const icp = e.getLargestInteractionContentfulPaint();
  return {
    type: e.navigationType,
    route: new URL(e.name).pathname,
    lcpMs: icp ? Math.round(icp.largestContentfulPaint.startTime - e.startTime) : 'no paint',
  };
}));

Expect one row per route change with type push, replace or traverse, the route path and lcpMs in milliseconds. In our test the menu row read push, /restaurant/1042 and about 615. A route change that paints nothing gets no entry and no row, so the no paint text is only a safety net.

An empty table means Chrome detected no soft navigation, so check the three rules in the section above.

Why register the observer early?

Chrome keeps only the first 50 soft-navigation entries in its buffer. The Chrome documentation warns about this for getEntriesByType, and our test showed a late observer hits the same wall. The W3C's Timing Entry Names Registry sets the same 50-entry limit.

After 60 route changes in Chromium 153, an observer registered at page load received all 60. getEntriesByType returned 50, and an observer created afterwards with buffered: true also received only 50.

ICP entries behave differently. By design, getEntriesByType never returns them, just like LCP entries. A buffered observer created late still receives them, but only the first 150. The same registry lists both rules, and Chromium 153 matched them after 170 route changes in our test.

How do you reset LCP, INP and CLS on every route change?

Treat every soft-navigation entry as the start of a new page. LCP comes from that route's ICP entries, while INP and CLS start again from zero, as on a fresh load.

  1. Check support

    If PerformanceObserver.supportedEntryTypes lacks soft-navigation, report hard-load metrics and mark the visit as unsupported. Where the Navigation API exists to flag URL changes, also report INP per route.

  2. Send the old route's numbers

    When a soft-navigation entry arrives, send the finished LCP, INP and CLS for the route the user just left.

  3. Start fresh

    Set INP and CLS to zero. Note the entry's name, startTime, navigationId and navigationType.

  4. Measure LCP from the tap

    Take the largest ICP entry's paint time and subtract the soft navigation's startTime, which is when the tap began.

  5. Report TTFB as zero

    Time to First Byte, the wait for the first byte of a new HTML page, has nothing to measure here. The Chrome documentation suggests reporting 0.

  6. Tag every beacon

    Include the route, navigationType and hard or soft, so fast first loads cannot hide slow routes.

Elements that stay on screen across routes, like a site header, are not repainted. The Chrome documentation says such paints are not considered, so they never become the new route's LCP.

Why one slow tap hurts two routes

The tap that causes a soft navigation is usually counted in the previous route's INP. A layout shift that happens before the URL updates may be counted there too.

Pause and predict: a tap handler blocks the main thread, the single thread that runs your JavaScript and lays out the page, for 300 ms, then routes to /cart. Which route pays for it?

Both did in our Chromium 153 test with the web-vitals library. In every run, the home route reported an INP of about 300 ms. The cart route's LCP was about 310 ms, because soft LCP starts at the tap.

So a slow click handler shows up as poor INP on one row and slow LCP on the next. Fix the handler first, before chasing images on the second route.

Should you use the web-vitals library?

Writing all this by hand is fiddly. The open-source web-vitals library has supported soft navigations since version 6.0.0, published on npm on 21 July 2026.

Pass reportSoftNavs: true to onLCP, onINP and onCLS. Each metric then carries navigationType, which reads soft-navigation for route changes, plus navigationId and navigationURL.

Use navigationURL rather than the current address, because metrics can arrive after the user has moved on. In our run with version 6.2.3, onTTFB reported 0 for every soft navigation.

Hard navigation vs soft navigation metrics when you measure per route
MetricHard navigation (full page load)Soft navigation (route change)
Starting pointWhen the page starts loadingWhen the triggering tap or key press started
LCPLast LCP entry before the first interactionLargest ICP entry's paint time minus the start time
INPInteractions from load until the first soft navigationRestarted, then only later interactions
CLSLargest burst of layout shifts until the first soft navigationRestarted from 0 for the new route
TTFBWait for the first byte of new HTMLReported as 0
Entry that starts itnavigationsoft-navigation

How do you turn route beacons into a per-route dashboard?

Collect the beacons on your server, group them by route and navigation kind, and compute p75 for each group. The team adds a /rum endpoint that stores each beacon as one row.

Route measurement pipeline

Notice that each route view becomes exactly one row, and the route and kind tags travel with it all the way to the p75 job.

Diagram connections:

  • Customer's browser to Beacon to /rum (sendBeacon on route change)
  • Beacon to /rum to RUM collector (POST /rum)
  • RUM collector to Beacon table (Insert row)
  • Beacon table to Hourly p75 job (Read last hour)
  • Hourly p75 job to Per-route dashboard (p75 per route)
  • Customer's browser: Builds one record per route view
  • Beacon to /rum: Route, kind, type and LCP as JSON
  • RUM collector: Checks and timestamps records
  • Beacon table: One row per route view
  • Hourly p75 job: Groups rows by route and kind
  • Per-route dashboard: One row per screen and kind

Here is the core of the hourly job, with a few sample rows. LCP values are milliseconds since the page load or the tap began.

python
from bisect import bisect_left
from collections import defaultdict
from statistics import quantiles

# (route, kind, lcp_ms) rows from the beacon table (made up)
beacons = [
    ("/", "hard", 1700), ("/", "hard", 2100),
    ("/restaurant/:id", "soft", 2900), ("/restaurant/:id", "soft", 3600),
    ("/restaurant/:id", "soft", 4300),
    ("/cart", "soft", 800), ("/cart", "soft", 1100),
]

groups = defaultdict(list)
for route, kind, lcp_ms in beacons:
    groups[(route, kind)].append(lcp_ms)

LABELS = ["good", "needs work", "poor"]  # good up to 2500 ms, poor above 4000 ms
for (route, kind), values in sorted(groups.items()):
    p75 = quantiles(values, n=4, method="inclusive")[2]
    label = LABELS[bisect_left([2500, 4000], p75)]
    print(f"{route} ({kind}): p75 LCP {p75:.0f} ms, {label}")

Running it with Python 3 prints three lines: / (hard) at 2000 ms, good; /cart (soft) at 1025 ms, good; and /restaurant/:id (soft) at 3950 ms, needs work.

statistics.quantiles with n=4 cuts the values into four groups, and the third cut point is p75. The inclusive method treats the rows as the whole population rather than a sample.

bisect_left picks the band, so exactly 2500 ms still counts as good and exactly 4000 ms as needs work, matching the official good and poor lines.

Grouping by kind keeps full loads apart from taps. A soft LCP starts at the tap and includes no HTML download or page start-up, so it is not directly comparable with a hard load's LCP.

The team borrows the 2.5 second LCP line as its route target, which is a team choice rather than an official threshold. The menu row fails because its banner photo downloads only after the menu data arrives.

The team starts loading the photo as soon as a finger touches a restaurant card. Menu p75 drops to 2.3 seconds (made up), and tickets fall to about 40 a week (made up).

What does CrUX show for single page apps in October 2026?

CrUX now publishes experimental soft navigation versions of all three Core Web Vitals. A web.dev post dated 13 October 2026 announced them with the October 2026 CrUX release.

  • The names are experimental_largest_contentful_paint_with_softnavs, experimental_interaction_to_next_paint_with_softnavs and experimental_cumulative_layout_shift_with_softnavs.
  • They are in the CrUX BigQuery dataset, a public table you query with SQL. They are also in the CrUX API and CrUX History API, which return one page or site. The CrUX Vis web tool also charts them for any page or whole site.
  • They blend soft navigations with hard ones, and a soft navigation's numbers are filed under the URL the user moved to, not the landing page.

Google's stable Core Web Vitals scores are unchanged while these run alongside them. The post says the team aims to graduate the metrics in May 2027, subject to feedback, and Search Console would then switch.

Expect the numbers to move. The post warns of month-to-month shifts and says navigation types and First Contentful Paint, when the first text or image appears, should come in the November release.

Your own RUM data still matters. CrUX shows public numbers at page or site level, while your beacons carry your own route names, your own targets and every browser you choose to measure.

Which browsers support soft navigations, and what is the fallback?

Chrome 151 and Edge 151 support soft navigation entries by default. As of 19 October 2026, Firefox and Safari do not, and neither has signalled support on Chrome Platform Status.

Firefox and Safari still give you useful numbers. Firefox 144 and Safari 26.2 support the interaction IDs that INP is built on. Firefox 122 and Safari 26.2 added LCP for the first load, while CLS remains Chromium-only.

For route changes there, the Chrome documentation suggests the Navigation API, a browser feature that fires an event when the URL changes. Firefox 147 and Safari 26.2 support it, so you can slice INP by route.

Paint timing after a route change can only be approximated without these entries. Keep any approximate LCP in its own group, never mixed with Chrome's measured values.

The team keeps it simple. Firefox and Safari visits report hard loads and per-route INP only, and the dashboard shows what share of route views it can measure.

How do you explain Core Web Vitals for single page apps in a system design interview?

Frame it as a measurement pipeline, then show where the browser's new signals fit and where they stop.

  1. State the gap

    Classic LCP stops at the first interaction, and INP and CLS pile every route onto the landing URL.

  2. Define a soft navigation

    A user action, a same-document URL change and a paint from that action, detected by a heuristic in Chrome and Edge 151 or later.

  3. Name the browser signals

    Soft-navigation entries mark route changes. Interaction-contentful-paint entries carry each route's LCP, measured from the tap.

  4. Restart and tag

    Restart LCP, INP and CLS per route, report TTFB as 0, and tag each record with route and navigation kind.

  5. Design the pipeline

    Observer registered early, beacon, collector, table, then p75 per route and kind on a schedule.

  6. Cover the gaps

    No Firefox or Safari support, CrUX metrics experimental with graduation aimed at May 2027, and the slow tap that lands on two routes.

Questions people ask

Does CrUX data include soft navigations yet?
Yes, as experimental metrics. Since the October 2026 release, CrUX publishes soft navigation versions of LCP, INP and CLS in BigQuery, the CrUX API and the CrUX History API. The stable Core Web Vitals scores are unchanged, and the team aims to graduate the new metrics in May 2027.
Do I need the web-vitals library to measure soft navigations?
No, but it saves work. The web-vitals library has supported soft navigations since version 6.0.0 and handles the restarts for LCP, INP and CLS. Turn on its reportSoftNavs option, and read navigationURL to see which route each metric belongs to.
Why is TTFB zero for a soft navigation?
Because no new HTML page is requested. Time to First Byte measures the wait for the first byte of a new document, and a soft navigation only redraws the existing page. The Chrome documentation suggests reporting 0, and web-vitals 6.2.3 did so in our test.
Does a route change that uses replaceState count?
Yes, in Chrome 147 and later. Chromium's changelog says replaceState support was added in Chrome 147, when pushState already counted. In our Chromium 153 test a replaceState route change produced an entry with navigationType replace. It still needs a user action and a contentful paint.
Why do I see interaction-contentful-paint entries without any route change?
Because ICP entries are reported for any interaction that causes a contentful paint, not only for route changes. Opening a filter drawer created one in our test. Match ICP entries to soft navigations using interactionId, or call getLargestInteractionContentfulPaint() on the soft-navigation entry.
Can I measure soft navigations in Firefox or Safari?
Not with these entries, as of 19 October 2026. Current versions of both browsers measure first-load LCP and support the Navigation API, so you can split INP by route yourself. Paint timing after a route change can only be approximated there.
Do soft navigations change Google's stable Core Web Vitals scores today?
No, as of 19 October 2026. The CrUX announcement says Google's stable Core Web Vitals scores are unchanged while the experimental soft navigation versions are evaluated alongside them. It says Search Console will switch to the new data once they graduate.

Sources and further reading

Practise this with Lala

Practise