Soft Navigations, Chrome Finally Sees More Than My First Page

Performance

Well, I have a small confession to make, since I renewed my website, every click you make after the first page is a soft navigation, and until a few months ago Chrome didn’t measure those by default, so as far as Core Web Vitals were concerned, my whole website was just one page, the one you landed on 😅

In this article I will tell you what soft navigations are, how you can finally measure them, and the bugs they caused on my website, with real numbers from it.

Soft navigations

What is a soft navigation?

Let me explain it with a house 🏠, a hard navigation is like moving to a new house, you click a link, the browser throws away the current page, asks the server for a new HTML document and builds everything from scratch.

A soft navigation is more like rearranging the furniture in the same house, JavaScript fetches what it needs, swaps the content of the page and changes the URL with the History API (pushState) or the Navigation API, so for you it looks like a new page, but for the browser it is still the same document. So everything that was in the house is still there, keep that in mind, we will come back to it.

This is how single-page apps work (React, Vue, Angular and friends), and yes, it is also how my website works, it is built with Astro and uses its View Transitions, so only the first page you open is a hard navigation, and every click after that is a soft one.

And no, being a static website doesn’t change that, every page is still a prebuilt HTML file, Astro just fetches it and swaps it in instead of letting the browser load it.

Why measuring them was a problem

Here is the thing, Core Web Vitals were designed around documents, LCP is reported once per document, and CLS and INP keep adding up for the whole life of that document.

So on a website like mine, the LCP is measured only for the page you landed on, and the INP and CLS of every page you visit after it get blamed on that first URL.

I wanted to see it with my own eyes, so I measured the same session on my website in both ways:

One session on medhat.dev, measured with the classic LCP observer and with the Soft Navigations API

The classic LCP observer reported exactly one entry for the whole session, while I visited 6 pages 🤯

The Soft Navigations API

Luckily, the Chrome team has been working on this since 2023, with a couple of origin trials (the final one in Chrome 147 to 149), and it is finally enabled by default since Chrome 151, which was released on July 28, 2026, and Edge got it in 151 as well.

Chrome treats a navigation as a soft one when three things happen:

  1. You start it with an action, like a click or a tap.
  2. The URL changes.
  3. Something new gets painted on the screen.

And when that happens, you get two new performance entries:

  • soft-navigation: one entry for every soft navigation, with the new URL as its name, a navigationType (push, replace or traverse), and a navigationId.
  • interaction-contentful-paint: the paints that happen after an interaction, with a largestContentfulPaint inside it, and this is how we get the LCP of a soft navigation.

On top of that, the entries you already know (LCP, layout shifts and event timings) got a navigationId too, so you can finally tell which page each of them belongs to.

Here is the small snippet I used to measure my own website, it collects the soft navigations, and later reads the largest paint of each one:

const softNavigations = [];
 
new PerformanceObserver((list) => softNavigations.push(...list.getEntries()))
  .observe({ type: 'soft-navigation', buffered: true });
 
// later, for example before the next navigation or when the page gets hidden
for (const navigation of softNavigations) {
  const icp = navigation.getLargestInteractionContentfulPaint();
  if (!icp) continue;
  const lcp = icp.largestContentfulPaint.startTime - navigation.startTime;
  console.log(navigation.name, `${Math.round(lcp)}ms`);
}

Notice that I subtract navigation.startTime, because all the timings are relative to the first hard navigation, not to the click, more on that in the gotchas below.

What I found on my own website

I went through my website the way you would read it, the home page, then the blog, then a post, then the speaking, gear and about pages, and I did that 5 times:

LCP per route on medhat.dev, median of 5 runs

PageNavigationLCPLCP element
/hard324msthe biggest photo in my speaking gallery
/blogsoft305msthe cover image of my latest post
/blog/appreciate-buttonsoft259msthe first screenshot in the post
/speakingsoft135mstext
/gearsoft244msthe AI generated image of my chair 😄
/aboutsoft163msmy profile photo

Lab data, Chrome 154 on desktop with a fast connection, so it is not what my real visitors get, but it shows the idea.

All the pages are fast, but the more interesting part is that before Chrome 151, Chrome didn’t report 5 of these 6 numbers at all, and while I was at it, I noticed a few more things:

  • The back button counts too: going back from a page showed up as a soft navigation as well.
  • The click belongs to the previous page: the click on “Speaking” was counted for the blog post I was reading, not for the speaking page, so the INP of a page includes the click that leaves it.
  • My sidebar can never be the LCP of a soft navigation: only the elements that repaint after the click count, and my sidebar stays as is, while on the first load it could.
  • Google Analytics was already fine: it sent a page_view for the route changes I clicked, since it listens to the history changes.

Gotchas worth knowing

Before you go and measure your own website, here are a few things to keep in mind, most of them come from the official docs, and some of them I learned the hard way:

  • Timings are relative to the hard navigation: subtract the startTime of the soft navigation, as in the snippet above.
  • TTFB is 0: there is no new document, so there is no time to first byte to report.
  • Match paints by the interaction: an interaction-contentful-paint entry can carry the navigationId of the previous page, so use getLargestInteractionContentfulPaint() or match by interactionId.
  • Use PerformanceObserver: performance.getEntriesByType() only keeps the first 50 entries.
  • Layout shifts right after the click may be excluded: shifts within 500ms of the interaction are expected, the same as with any user input.
  • Send the navigation type to your RUM: so you can compare hard and soft navigations instead of mixing them.

Don’t write it yourself, use web-vitals

Honestly, you probably don’t want to deal with all of the above by hand, and you don’t have to, the web-vitals library supports soft navigations since version 6, and all you need is one option:

import { onLCP, onINP, onCLS } from 'web-vitals';
 
const options = { reportSoftNavs: true };
 
onLCP(sendToAnalytics, options);
onINP(sendToAnalytics, options);
onCLS(sendToAnalytics, options);

Every metric then tells you which page it belongs to (navigationURL, navigationId and navigationType), and if you prefer to see it, Chrome DevTools shows the soft navigations in the Performance panel too, in the trace, in the live metrics and in the insights.

Good news: since October 2026, CrUX publishes experimental versions of LCP, INP and CLS that include soft navigations, you can find them in the CrUX BigQuery dataset, the CrUX API and the CrUX History API. But as the name says they are experimental, so the stable Core Web Vitals scores didn’t change, and Search Console will only switch to them once they graduate, which is planned for May 2027.

Living with soft navigations: the bugs I had right after the renewal

Remember the house? with soft navigations you never move to a new one, so the browser stops cleaning up after you, and everything you left behind is still there, here are the bugs that this caused on my website in the first week after the renewal.

1. The appreciate count that moved

On a post page, the count of the appreciate button sits above the icon, so I wrote this:

function initSocialElement() {
  const appreciateButton = document.querySelector('appreciate-button');
  // ...set data-count="top" on desktop
}
 
document.addEventListener('DOMContentLoaded', initSocialElement);
document.addEventListener('astro:page-load', initSocialElement);
document.addEventListener('astro:after-swap', initSocialElement);

Looks fine, right? but hear me out, the document never changes, so these listeners stay alive after you leave the post, and when you go back to the blog page they run again, document.querySelector finds the button of the first card, and its count jumps to the top until you refresh the page.

The fix was simply to look only inside the post:

const appreciateButton = document.querySelector('#shareSocial appreciate-button');

2. Comments that never loaded

This one was worse, the comments script was an inline script, and Astro runs inline scripts again on every navigation, so every post you visited added 3 more listeners to the document.

Then on the blog page, which has no comments at all, these listeners tried to create the comments widget, it crashed with Remark42: Can't find root node., and that crash broke the widget for every post after it, so the next posts were stuck on “Comments loading…” forever, and guess what? it also created the widget twice on every direct visit 🤦‍♂️

The fix was to move it to a bundled script that runs only once, listen to astro:page-load only, and skip the pages without comments, and after that the comments showed up in 0.4 to 0.7 seconds on every post, instead of never.

3. Register global listeners once

So when I added a small confetti burst for when you fill the appreciate button of a post, I had learned my lesson and did it the right way from the start:

// Registered once per page load: a document listener survives View Transitions
// navigation, so adding it in initSocialElement would stack one per post visited.
document.addEventListener('appreciate:maxed', (event) => {
  // ...only react to the button of the current post
});

And here is a quick summary of what changes when you move to soft navigations:

Hard navigationSoft navigation
JavaScript statestarts fresheverything stays, including old listeners
Your scriptsrun againdepends on the framework, in Astro inline scripts run again and bundled ones don’t
Third-party widgetsload againyou may have to destroy and create them yourself
Analytics page viewsautomaticonly if it listens to history changes
Core Web Vitalsmeasuredmeasured with the Soft Navigations API in Chrome 151+
CrUX field datayesexperimental, since October 2026

Browser support

The important thing to understand here is that soft navigations themselves work in every browser, since they are built on the History API, what differs is the support for measuring them and for the APIs around them:

FeatureBaselineChrome / EdgeFirefoxSafari
History API (pushState)Widely available5 / 1245
Navigation APINewly available (2026)10214726.2
Same-document View TransitionsNewly available (2025)11114418
Cross-document View TransitionsLimited126❌18.2
Speculation RulesLimited109❌❌
Soft Navigations APILimited151❌❌

So for now, measuring soft navigations is a Chrome and Edge thing, Mozilla and WebKit didn’t take a position on it yet, and their standards positions issues are still open (Mozilla, WebKit).

And to be fair, if your website is a multi-page website, cross-document View Transitions with Speculation Rules can give you the same smooth feeling with real hard navigations, so none of the bugs above, but as you can see in the table, they are not supported everywhere yet.

Thanks Barry

A special thanks to Barry Pollard, my perf lead at the Chrome team, who wrote the official docs about soft navigations together with Michal Mocny, and has been pushing this API for years, so we can finally measure websites the way users really experience them 🙏

Conclusion

Soft navigations are not new, but measuring them is, so if your website is a single-page app, or uses something like Astro’s View Transitions, Chrome now sees all the pages after the first one, and you should too, use web-vitals with reportSoftNavs, send the navigation type with your metrics, and remember the house, with soft navigations the browser doesn’t clean up after you 😉

If you have any questions, or you measured your own website and found something interesting, let’s discuss it in the comments below, they load now, I promise 😄, and don’t forget to press the appreciate button if you liked this post.

Resources

Tot ziens 👋

Comments loading…