How to measure CWVs on SPAs
This page is also available in: Español
Reading time: 7 minsOne 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:
- By turning on the Experimental Web Platform features flag at
chrome://flags/#enable-experimental-web-platform-features
- Or using the
--enable-experimental-web-platform-features
command line argument when launching Chrome.
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:
- TTFB: Reported as 0. In the future, Google may support more precise ways of knowing which request is the soft navigation’s “navigation request” and will be able to have more precise TTFB measurements.
- FCP: The time of the next contentful paint, relative to the soft navigation start time. Existing paints present from the previous navigation are not considered - only new contentful paints. Therefore, the FCP will be >= 0.
- LCP: The time of the next largest contentful paint, relative to the soft navigation start time. Like FCP, existing paints present from the previous navigation are not considered. Therefore, the LCP will be >= 0. As usual, this will be reported upon an interaction (for example by clicking on the page), or when the page is backgrounded or navigated away from, as only then can the LCP be finalized.
- CLS: The largest window of shifts between the navigation times. Similar to LCP, this is only finalized when the page is backgrounded or navigated away from. A 0 value is reported if there were no shifts on that page.
- FID: Currently only the first FID for the page is reported by Chrome, so this is not yet reported for soft navigations.
- INP: The INP between the navigation times. As usual this will be reported upon an interaction, or when the page is backgrounded as only then can the INP be finalized. A 0 value is not reported if there are no interactions.
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: