How to measure CWVs on SPAs

This page is also available in: Español

Reading time: 7 mins

One of the most challenging questions to answer when talking about Core Web Vitals is how to measure them in a Single-Page Application (SPA). In this article, I'll clarify the bottlenecks and the current state of this topic.

But before going into specifics, let’s talk about soft navigations. A soft navigation refers to the seamless transition between web pages, where a new page is not entirely loaded but changed by JavaScript. While this technique has recently become quite popular through several frameworks, there are limitations to how it affects the Core Web Vitals.

Core Web Vitals are a set of metrics that Google uses to measure website performance, including load speed, interactivity, and visual stability. These metrics are vital because they impact the website's user experience and, potentially, search engine results. Google introduced Core Web Vitals in May 2020, and since then, it has become a significant factor in website development.

Challenges

As I mentioned before, while SPAs can offer a more seamless experience, they create challenges when measuring Core Web Vitals. For instance, a typical SPA page may have several navigation options, which the user can click to access different parts of the application. With soft navigations, these options are not considered unique pages, and each click merely loads new content onto the current page. The URL can be modified through JavaScript, emulating the multi-page applications' (MPAs) navigation, but to the browser they are seen as updates to the current page, rather than new pages.

The Chrome User Experience Report (CrUX) is based on “hard” page navigations. This means metrics such as FCP, LCP, or FID will only be reported for the first viewed page. Other metrics such as CLS or INP are measured for the entire lifecycle of a page. Hence, new CLS and INP values may be collected, but assigned to the first viewed page - even if they occur after one or some soft navigations. Additionally you only get one overall value for CLS and INP, rather than one value per each application page.

To overcome these limitations, developers can adopt several strategies to ensure more useful Core Web Vitals measurements. One approach is to create custom performance metrics that accurately measure user experience in SPAs.

Developers can also consider using other rendering options, such as server-side rendering (SSR) or static site generators (SSG) to overcome some of the limitations of SPAs. Under these architectures, developers can ensure users have hard page navigations and avoid all the SPA inconveniences when measuring CWV.

However, these options are not always possible.

Enabling soft navigations in Chrome (experimental)

Since Chrome 110, you can enable soft navigations and have the opportunity to start measuring Core Web Vitals. According to Chrome’s documentation, you can enable it in two different ways:

Note: For websites that wish to enable this for all their visitors to see the impact, there is an origin trial running from Chrome 110 to Chrome 112 which can be enabled by signing up for the trial and including a meta element with the origin trial token in the HTML or HTTP header.

Measuring Core Web Vitals per soft navigation

After enabling the Experimental Web Platform features flag, you will see the following information in the Console after each soft navigation.

  A soft navigation has been detected: https://example.com/page

And now you’re almost ready to start measuring CWVs for each soft navigation.

Using a PerformanceObserver

If you’re using a PerformanceObserver, you need to include includeSoftNavigationObservations: true in your observe call. For instance, a basic example to display the LCP in the console:

  new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
console.log('LCP candidate:', entry.startTime, entry);
}
}).observe({type: 'largest-contentful-paint', buffered: true, includeSoftNavigationObservations: true});

Once you implement this, you will be able to see the different LCP candidates for each soft navigation in the console. Something like this:

  A soft navigation has been detected: https://www.example.com/page2
LCP candidate: 6054.2999
▶ LargestContentfulPaint {renderTime: 6054.299, loadTime: 0, size: 3591, id: '', url: '', ...}
LCP candidate: 6177.7999
▶ LargestContentfulPaint {renderTime: 6177.799, loadTime: 6155, size: 166500, id: '', url: 'https://www.example.com/image.jpg', ...}

For every LCP candidate, you'll get an object with all the related details such as the HTML element, URL (if the LCP candidate is an external file), loadTime, and size, among others.

As well as LCP, you can use a PerformanceObserver to display the details for the other web vitals as desired. For example, the following code will display the details for CLS:

  let clsValue = 0;
let clsEntries = [];

let sessionValue = 0;
let sessionEntries = [];

new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
// Only count layout shifts without recent user input.
if (!entry.hadRecentInput) {
const firstSessionEntry = sessionEntries[0];
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];

// If the entry occurred less than 1 second after the previous entry and
// less than 5 seconds after the first entry in the session, include the
// entry in the current session. Otherwise, start a new session.
if (sessionValue &&
entry.startTime - lastSessionEntry.startTime < 1000 &&
entry.startTime - firstSessionEntry.startTime < 5000) {
sessionValue += entry.value;
sessionEntries.push(entry);
} else {
sessionValue = entry.value;
sessionEntries = [entry];
}

// If the current session value is larger than the current CLS value,
// update CLS and the entries contributing to it.
if (sessionValue > clsValue) {
clsValue = sessionValue;
clsEntries = sessionEntries;

// Log the updated value (and its entries) to the console.
console.log('CLS:', clsValue, clsEntries)
}
}
}
}).observe({type: 'layout-shift', buffered: true, includeSoftNavigationObservations: true});

Using web-vitals.js

An easier way is to use the web-vitals.js library to measure them, which takes care of many of the calculations you may need to do from the Performance Observer entries, as shown in the above CLS example. To do this use the soft-navs branch and include {reportSoftNavs: true} in the functions' calls. For example, using this basic code to display FCP, LCP and CLS:

  import {
onFCP,
onLCP,
onCLS
} from 'web-vitals'

onFCP(({value}) => console.log('FCP: ', value), {reportSoftNavs: true});
onLCP(({value}) => console.log('LCP: ', value), {reportSoftNavs: true});
onCLS(({value}) => console.log('CLS: ', value), {reportSoftNavs: true});

And then you will get this kind of information in the console:

  A soft navigation has been detected: https://www.example.com/page2
CLS: 0.00192837465
FCP: 32.6739203094
LCP: 803.83929999

The web-vitals library currently reports the following metrics for soft navigations:

Conclusions

This is an exciting experiment to start measuring a common architecture on the modern web that is currently missing from CrUX. Still, it is a first step, and again, it is an experiment. However, Google is really looking forward to gathering feedback from it. So, if you're testing this approach, feel free to share your feedback with them: