In this blog, we’ve discussed measuring and improving our performance using the Lighthouse audits. Google continued by introducing new tools that emphasize performance. Some time ago, they introduced the Web Vitals standard. In this article, we go through it and provide examples of measuring the Web Vitals metrics with React.
Introducing Web Vitals
There are many factors Google takes into account when indexing our page. Measuring performance is becoming a part of Search Engine Optimization more and more. The goal of Web Vitals is to provide clear guidance on what metrics matter and how to measure them.
The idea behind Web Vitals is that we can measure them on real users interacting with the page. The results can vary based on the network conditions or the device’s capabilities. Even if we develop our website on the latest hardware, our users might still use old phones and computers, and it is essential to accommodate that when performing our tests.
The way our users perceive the performance is not limited to the loading time. It seems to load faster if a page renders content even before the loading is finished. Also, a page not responsive when the user interacts with it gives an impression of poor performance and user experience.
The Web Vitals standard aims to measure all of the above. We can divide its metrics into two groups.
Core Web Vitals
Google marks the crucial parts of the Web Vitals standard as the Core Web Vitals. They currently focus on three aspects of the user experience: loading, interactivity, and visual stability.
Largest Contentful Paint (LCP)
The largest contentful paint reports the render time of the largest image or text block visible in the viewport. We should aim to have an LCP of 2.5 seconds or less.
Cumulative Layout Shift (CLS)
The cumulative layout shift detects sudden changes to the webpage. If a text or a link moves unexpectedly, we can end up clicking on something else by accident.
A layout shift occurs every time a visible element changes its position. CLS measures the largest burst of layout shifts where layout shifts occur rapidly, one after another. Google states that we should optimize our website for a CLS score of 0.1 or less.
First Input Delay (FID)
First input delay is the moment between the user first interacting with the page and the browser beginning to process the event handler in response. In simpler terms, FID measures the delay in the event processing.
Google wants us to aim for the first input deal of 100 milliseconds or less.
Other Web Vitals
Besides the above crucial metrics, there are other parts of the Web Vitals standard that we should mention.
Time to First Byte (TTFB)
The time to the first byte measures the delay between the moment a user requests our page and when the first byte of the response arrives.
First Contentful Paint (FCP)
The first contentful paint measures the delay between when the page starts loading and when any part of the content is visible. Again, we should strive to have an FCP of 1.8 seconds or lower.
Measuring Web Vitals with Create React App
Google developed a web-vitals library to help us with measuring Web Vitals. When we create an application with Create React App, it uses web-vitals out of the box. Let’s use CRA so that we can investigate it.
1 |
npx create-react-app react-vitals --template typescript |
When we do the above, we end up with an index.tsx file that calls the reportWebVitals function.
index.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById('root') ); // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals reportWebVitals(); |
When we look into the reportWebVitals.tsx file, we can see that it accepts a function.
reportWebVitals.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import { ReportHandler } from 'web-vitals'; const reportWebVitals = (onPerfEntry?: ReportHandler) => { if (onPerfEntry && onPerfEntry instanceof Function) { import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { getCLS(onPerfEntry); getFID(onPerfEntry); getFCP(onPerfEntry); getLCP(onPerfEntry); getTTFB(onPerfEntry); }); } }; export default reportWebVitals; |
Using the web-vitals library
An interesting part about the above code is that it loads the web-vitals library asynchronously only if we pass a function to reportWebVitals. It means that even though Create React App uses web-vitals by default, it won’t increase the JavaScript bundle code we serve to our users if we don’t care about the metrics.
The web-vitals is very compact though and weights about 1K when served.
In the ReportHandler type, we can see that when we pass a function to reportWebVitals, we get a bunch of information about the metric.
1 2 3 |
interface ReportHandler { (metric: Metric): void; } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
interface Metric { // The name of the metric (in acronym form). name: 'CLS' | 'FCP' | 'FID' | 'LCP' | 'TTFB'; // The current value of the metric. value: number; // The delta between the current value and the last-reported value. // On the first report, `delta` and `value` will always be the same. delta: number; // A unique ID representing this particular metric instance. This ID can // be used by an analytics tool to dedupe multiple values sent for the same // metric instance, or to group multiple deltas together and calculate a // total. It can also be used to differentiate multiple different metric // instances sent from the same page, which can happen if the page is // restored from the back/forward cache (in that case new metrics object // get created). id: string; // Any performance entries used in the metric value calculation. // Note, entries will be added to the array as the value changes. entries: (PerformanceEntry | FirstInputPolyfillEntry | NavigationTimingPolyfillEntry)[]; } |
Creating a report handler
Create React App suggests that we start by using console.log to view the reports.
reportHandler.tsx
1 2 3 4 5 6 7 |
import { Metric } from 'web-vitals'; function reportHandler(metric: Metric) { console.log(metric); } export default reportHandler; |
index.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; import reportHandler from './reportHandler'; ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById('root') ); reportWebVitals(reportHandler); |
The web-vitals library calls our reportHandler every time a new metric value is available. Although, some of the metrics are not reported at the very beginning. For example, first input delay (FID) is not reported until the user interacts with the page.
Sending the metrics result
There are quite a few approaches we can choose from when it comes to gathering metrics. One way would be to create an endpoint that stores the metrics to analyze them later.
reportHandler.tsx
1 2 3 4 5 6 |
import { Metric } from 'web-vitals'; function reportHandler(metric: Metric) { const payload = JSON.stringify(metric); navigator.sendBeacon('/analytics', payload); } |
Above, we use navigator.sendBeacon instead of fetch. It does not wait for a response and can send a POST request even if the user is navigating away from the website.
If you worry about browser compatiblity, you can first check if navigator.sendBeacon is available and use fetch as a fallback.
Instead of creating our backend solution for metrics, we can use Google Analytics.
reportHandler.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import { Metric } from 'web-vitals'; // event values can only contain integers function getEventValueFromMetric(metric) { if (metric.name === 'CLS') { return Math.round(value * 1000); } return Math.round(value); } function reportHandler(metric: Metric) { ga('send', 'event', { eventCategory: 'Web Vitals', eventAction: metric.name, eventValue: getEventValueFromMetric(metric), eventLabel: metric.id, nonInteraction: true, }); } |
Above, we use the nonInteraction flag because our events don’t directly indicate that the user performed an action. Therefore, it doesn’t affect bounce rates that tell us if the user performed some actions before leaving our website.
The web-vitals library also suggests using the Google Tag Manager
The web-vitals library under the hood
Behind the scenes, the web-vitals library creates instances of the PerformanceObserver. We shouldn’t call the Web Vitals functions such as getCLS and getFID more than once because the observer takes care of the metrics for the entire lifetime of our application.
After looking at caniuse.com, we see that the old browsers do not support the PerformanceObserver. If that concerns you, you can use a polyfill prepared by the authors of the web-vitals library. To do that, we need to import web-vitals/base instead of web-vitals and attach the contents of the dist/polyfill.js into the <head> section of our website.
1 2 3 4 5 6 7 |
<head> <script> !function() { // ... }(); </script> </head> |
Summary
In this article, we’ve gone through the Web Vitals initiative and explained various metrics that it involves. It includes the Core Web Vitals and metrics such as the first contentful paint. We’ve also inspected the code Create React App generates for us out of the box and how to handle the Web Vitals metrics. Finally, after studying how the web-vitals library works under the hood, we’ve concluded that we might need a polyfill if we care about the browsers such as the Internet Explorer.
With the changes Google makes to its algorithms, the performance of our page is getting more significant for SEO. Also, it is always a good thing to care about the user experience, and the performance is an integral part of how the users perceive our application. Because of that, it is worth exploring the Web Vitals standard.