How to measure, debug, and optimize Core Web Vitals – the essential web signals for Google? Let’s see the different data you can collect (field data and lab data), and how you can use it to improve your website’s performance and user experience.
The original article by Phil Walton is in English on web.dev
Google currently offers two categories of tools to measure and optimize Web Vitals:
- Lab data tools such as Lighthouse , where your web page is loaded in an environment that simulates various browsing conditions (slow network and low-end mobile device);
- Field data tools such as Chrome User eXperience report (CrUX), which is based on aggregated data from real Chrome users. Note that the field data reported by tools such as PageSpeed Insights and Search Console comes from CrUX data.
While field tools provide more accurate data – as they reflect the experience of real users – lab tools are recommended to help you identify and troubleshoot performance issues on your web pages. (Translation: This is the distinction between Synthetic Monitoring and Real User Monitoring (RUM) which you can find more details on in this article ).
CrUX data is more representative of your page’s actual performance, but knowing your CrUX scores is unlikely to help you figure out how to improve your performance.
Lighthouse , on the other hand, will let you generate a report to help you identify issues and list recommendations to fix them, and optimize your loading speed. However, Lighthouse only makes suggestions for performance issues that it discovers at the time the page loads. It does not detect issues that might only manifest themselves as a result of user interaction, such as scrolling or clicking buttons on a web page.
This is where a central question arises: how do you capture the information needed to know how to achieve improvement in Web Vitals metrics, from real users in the field?
This article details the APIs you can use to collect additional information for improvement for each of the Core Web Vitals metrics (CLS, LCP, and FID), and provides methods to report this data into your existing analytics tool.
Core Web Vitals: APIs to detect performance issues
Cumulative Layout Shift (CLS)
Of all the Core Web Vitals metrics, CLS is perhaps the one for which real-world data collection is most important. CLS is measured over the life of the page, so how a user interacts with the web page (how far they scroll , what they click on, etc.) can have a significant impact on layout changes and where elements move around in the browser. Consider the following PageSpeed Insights report for the URL: web.dev/measure

The Lab data value reported by Lighthouse for CLS is different from the Field data value reported by CrUX, and this makes sense if you consider that the web.dev/measure page has a lot of interactive content, which cannot be appreciated by Lighthouse tests.
Now, even if you factor in the fact that user interaction affects the terrain data, you still need to know what elements are moving on the page to lead to that 0.45 score at the 75th percentile. You can do this using the LayoutShiftAttribution interface .
CUMULATIVE LAYOUT SHIFT: HOW TO ASSIGN A LAYOUT SHIFT
The LayoutShiftAttribution interface is exposed on every layout-shift entry emitted by the Layout Instability API .
For a detailed explanation of these two interfaces, you can check out the article “ Debug layout shifts ”. The most important thing to remember is that it is possible to observe every layout change that occurs on a web page, as well as the elements that move in the browser.
Here is an example of code that captures each layout shift ( Layout Shifts ), along with the elements affected:
[pastacode lang=”javascript” manual= »new%20PerformanceObserver((list)%20%3D%3E%20%7B%0A%20for%20(const%20%7Bvalue%2C%20startTi me%2C%20sources%7D%20of%20list.getEntries())%20%7B%0A%20%20%20%2F%2F%20Log%20the%20shift%2 0amount%20and%20other%20entry%20info.%0A%20%20%20console.log(‘Layout%20shift%3A’%2C%20%7Bv alue%2C%20startTime%7D)%3B%0A%20%20%20if%20(sources)%20%7B%0A%20%20%20%20%20for%20(const%2 0%7Bnode%2C%20curRect%2C%20prevRect%7D%20of%20sources)%20%7B%0A%20%20%20%20%20%20%20%2F%2F %20Log%20the%20elements%20that%20shifted.%0A%20%20%20%20%20%20%20console.log(‘%20%20Shift% 20source%3A’%2C%20node%2C%20%7BcurRect%2C%20prevRect%7D)%3B%0A%20%20%20%20%20%7D%0A%20%20% 20%7D%0A%20%7D%0A%7D).observe(%7Btype%3A%20’layout-shift’%2C%20buffered%3A%20true%7D)%3B%0A » message= » » highlight= » » provider= »manual »/]
Measuring and sending data to your analytics tool for every layout change that occurs is not desirable, however, by monitoring all changes you can identify which ones are problematic for your CLS, and therefore for your user experience, so that you can correct and achieve improvement.
So the goal is not to identify and fix every layout change that happens for every user, but rather to identify the changes that affect the most users and thus contribute the most to your page’s 75th percentile CLS score.
Additionally, you don’t need to calculate the largest source element every time there is an offset, you only need to do it when you need to send the CLS value to your analysis tool.
The following code retrieves a list of layout-shift entries that contributed to the CLS, and returns the largest source element of the largest shift:
[pastacode lang=”javascript” manual= »function%20getCLSDebugTarget(entries)%20%7B%0A%20const%20largestShift%20%3D%20entries.r educe((a%2C%20b)%20%3D%3E%20%7B%0A%20%20%20return%20a%20%26%26%20a.value%20%3E%20b.value% 20%3F%20a%20%3A%20b%3B%0A%20%7D)%3B%0A%20if%20(largestShift%20%26%26%20largestShift.sour ces)%20%7B%0A%20%20%20const%20largestSource%20%3D%20largestShift.sources.reduce((a%2C%20b )%20%3D%3E%20%7B%0A%20%20%20%20%20return%20a.node%20%26%26%20a.previousRect.width%20*%20 a.previousRect.height%20%3E%0A%20%20%20%20%20%20%20%20%20b.previousRect.width%20*%20b.pre viousRect.height%20%3F%20a%20%3A%20b%3B%0A%20%20%20%7D)%3B%0A%20%20%20if%20(largestSourc e)%20%7B%0A%20%20%20%20%20return%20largestSource.node%3B%0A%20%20%20%7D%0A%20%7D%0A%7D%0A » message= » » highlight= » » provider= »manual »/]
Once you have identified the most important element contributing to the biggest layout change, you can report it to your analytics tool.
The element that has the greatest impact on Cumulative Layout Shift for a given web page will likely vary from user to user, but if you aggregate these elements across all users, you will be able to generate a list of elements that affect the greatest number of users .
Over time, once you identify and fix the root cause of layout shifts for these elements, the smallest changes will become the “worst” for your CLS, and eventually, all reported shifts will then be small enough that your web pages fall within the “good” 0.1 threshold !
Some metadata may be useful to capture along with the largest offset source element:
- the time when the largest layout shift occurs;
- the URL at the time the largest layout change occurs (for websites that dynamically update URLs, such as Single Page Applications).
Largest Contentful Paint (LCP)
For LCP improvement from field data, you need to know which specific element is considered the largest element on the page when loaded (the LCP candidate element).
Note that it is entirely possible – in fact, quite common – for the LCP candidate element to be different from one user to another for the same page.
There are several reasons for this:
- Users’ devices have different screen resolutions, resulting in different layouts and therefore different visible elements in the window.
- Users don’t always load pages that they scroll to the top of. Often, links contain fragment identifiers , or even text fragments , which makes it possible for your pages to load and display in different positions for different users.
- Content can be personalized for a given user, so the LCP candidate item may vary from user to user.
For these reasons, you cannot make precise and definitive assumptions about the LCP candidate element for all your users, but you must measure it based on the behavior of real users.
IDENTIFY THE LCP CANDIDATE ELEMENT
To determine the LCP candidate element in JavaScript, you can use the Largest Contentful Paint API , the same API you use to determine the LCP time value.
From a list of largest-contentful-paint entries , you can determine the LCP candidate element by looking at the last entry:
[pastacode lang= »javascript » manual= »function%20getLCPDebugTarget(entries)%20%7B%0A%20const%20lastEntry%20%3D%20entries%5Bentries.length%20-%201%5D%3B%0A%20return% 20lastEntry.element%3B%0A%7D%0A » message= » » highlight=”” provider=”manual”/]
Warning: As explained about the LCP metric , the LCP candidate element can change during page load, which requires a bit more investigation to identify the “final” LCP candidate element. The easiest way to identify and measure the “final” LCP candidate element is to use the web-vitals JavaScript library , as illustrated in the example we will detail below.
Once you know the LCP candidate element, you can send it to your analysis tool along with the metric value. As with CLS, you can then identify the most important elements to optimize first.
Here is some metadata that may be useful to capture with the LCP candidate element:
- the URL of the image source (if the LCP candidate element is an image);
- the font family (if the LCP candidate element is text and the page uses web fonts ).
First Input Delay (FID)
To optimize FID from field data, it is important to remember that FID only measures the overall latency following a user’s first interaction . This means that what the user interacted with may not necessarily be top of mind in the Main Thread at the time they interact.
For example, many JavaScript applications that support server-side rendering will provide static HTML that can be rendered on the screen before it is responsive to the user’s click—that is, before the JavaScript required to render the interactive content has finished loading.
For these types of applications, it can be very important to know whether the first input, or interaction, occurred before or after hydration . If it turns out that many people are trying to interact with the page before hydration is complete, consider rendering your web pages in a disabled or loading state, rather than in a state that appears interactive.
If your application framework reports the hydration timestamp, you can easily compare it to the first-input timestamp to determine whether the first input occurred before or after hydration. If your framework doesn’t report this timestamp, or doesn’t use hydration at all, another useful signal might be whether the input occurred before or after the JavaScript finished loading.
The DOMContentLoaded event fires once the web page’s HTML has been completely loaded and parsed, which includes waiting for any synchronous, deferred, or module scripts (including any statically imported modules) to load. So you can use the timing of this event and compare it to the time the FID occurred.
The following code takes a first-input and returns “ true ” if the first input occurred before the DOMContentLoaded event ended :
[pastacode lang=”javascript” manual= »function%20wasFIDBeforeDCL(fidEntry)%20%7B%0A%20const%20navEntry%20%3D%20performance.getEntriesByType(‘navigation’)% 5B0%5D%3B%0A%20return%20navEntry%20%26%26%20fidEntry.startTime%20%3C%20navEntry.domContentLoadedEventStart%3B%0A%7D%0A » message= » » highlight= » » provider= »manual »/]
If your page uses async scripts or dynamic import() statements to load JavaScript, the DOMContentLoaded event may not be a relevant signal. Instead, you might consider using the load event , or if there is a particular script that you know takes a long time to execute, you might use the Resource Timing entry for that script.
IDENTIFY THE TARGET ELEMENT FOR THE FID
Another useful signal for optimizing First Input Delay is the element the user is interacting with. While interaction with the element itself does not contribute to FID (remember that FID is just the latency in total event latency), knowing what elements your users are interacting with is helpful in determining how best to improve FID.
For example, if the vast majority of your user’s first interactions happen on a particular element, you might consider including the JavaScript code needed for that element in the HTML, and lazyloading the rest. To get the element associated with the first interaction, you can reference the target property of the first-input input :
[pastacode lang= »javascript » manual= »function%20getFIDDebugTarget(entries)%20%7B%0A%20return%20entries%5B0%5D.target%3B%0A%7D%0A » message= » » highlight= » » provider = »manual »/]
Here is some metadata that may be useful to capture with the target element of the FID:
- the event type (such as mousedown , keydown , pointerdown );
- any relevant Long Task attribution data for a long task that occurred at the same time as the first interaction or input (useful if the page loads third-party scripts ).
How to use the Web-Vitals JavaScript library
The sections above suggest data to include in the data you send to your analytics tool to optimize your Core Web Vitals.
Each of the examples associates the Web Vitals metric value retrieved in JavaScript by leveraging the performance object with a DOM element that can be used to troubleshoot issues affecting that metric.
These examples are intended to work with the web-vitals JavaScript library , which exposes the list of performance inputs on the Metric object passed to each callback function.
If you combine the examples listed above with the web-vitals functions , the result will look like this:
[pastacode lang=”javascript” manual= »import%20%7BgetLCP%2C%20getFID%2C%20getCLS%7D%20from%20’web-vitals’%3B%0A%0Afunction%20getSelector(node%2C%20maxLen%20%3D%20100)%20%7B %0A%20let%20sel%20%3D%20 »%3B%0A%20try%20%7B%0A%20%20%20while%20(node%20%26%26%20node.nodeType%20!%3D%3D%209)%20%7B%0A% 2 0%20%20%20%20const%20part%20%3D%20node.id%20%3F%20’%23’%20%2B%20node.id%20%3A%20node.nodeName.t oLowerCase()%20%2B%20(%0A%20%20%20%20%20%20%20(node.className%20%26%26%20node.className.length) %20%3F%0A%20%20%20%20%20%20%20′.’%20%2B%20Array.from(node.classList.values()).join(‘.’)%20% 3A%20 “)%3B%0A%20%20%20%20%20if%20(sel.length%20%2B%20part.length%20%3E%20maxLen%20-%201)%20return%20sel%20%7C %7C%20part%3B%0A%20%20%20%20%20sel %20%3D%20sel%20%3F%20part%20%2B%20’%3E’%20%2B%20sel%20%3A%20part%3B%0 A%20%20%20%20%20if%20(node.id)%20break%3B%0A%20%20%20%20%20node%20%3D% 20node.parentNode%3B%0A%20%20%20%7D%0A%20%7D%20catch%20(err)%20%7B%0A%20%20%20%2F%2F%20Do%20nothing…% 0A%20%7D%0A%20return%20sel%3B%0A%7D%0A %0Afunction%20getLargestLayoutShiftEntry(entries)%20%7B%0A%20return%20entries.reduce((a%2C%20b)%20%3D%3E%20a%20%26%26%20a.value%20%3E% 20b.v alue%20%3F%20a%20%3A%20b)%3B%0A%7D%0A%0Afunction%20getLargestLayoutShiftSource(sources)%20%7B%0A%20return%20sources.reduce((a%2C%20b)% 20%3 D%3E%20%7B%0A%20%20%20return%20a.node%20%26%26%20a.previousRect.width%20*%20a.previousRect.height%20%3E%0A%20%20 %20%20%20%20%20b.previousRe ct.width%20*%20b.previousRect.height%20%3F%20a%20%3A%20b%3B%0A%20%7D)%3B%0A%7D%0A%0Afunction%20wasFIDBeforeDCL(fidEntry)%20 %7B%0A%20const%2 0navEntry%20%3D%20performance.getEntriesByType(‘navigation’)%5B0%5D%3B%0A%20return%20navEntry%20%26%26%20fidEntry.startTime%20%3C%20navEntr y.domContentLoadedEventStart%3B%0A%7D%0A%0Afunction%20getDebugInfo(name%2C%20entries%20%3D%20%5B%5D)%20%7B%0A%20%2F%2F%20In%20some%20cases %20there%20won’t%20be%20any%20entries%20(eg%20if%20CLS%20is%200%2C%0A%20%2F%2F%20or%20for%20LCP%20after%20a%20bfcache%20restore)% 2C%20so%20 we%20have%20to%20check%20first.%0A%20if%20(entries.length)%20%7B%0A%20%20%20if%20(name%20%3D%3D%3D%20’LCP’ )%20%7B%0A%20%20%20%20%20const%20 lastEntry%20%3D%20entries%5Bentries.length%20-%201%5D%3B%0A%20%20%20%20%20return%20%7B%0A%20%20%20%20%20%20 %20debug_target%3A%20getSelector (lastEntry.element)%2C%0A%20%20%20%20%20%20%20event_time%3A%20lastEntry.startTime%2C%0A%20%20%20%20%20%7D%3B%0A% 20%20%20%7D%20else%20if%20( name%20%3D%3D%3D%20’FID’)%20%7B%0A%20%20%20%20%20const%20firstEntry%20%3D%20entries%5B0%5D%3B%0A%20% 20%20%20%20return%20%7B%0A%20%20%20%20% 20%20%20debug_target%3A%20getSelector(firstEntry.target)%2C%0A%20%20%20%20%20%20%20debug_event%3A%20firstEntry.name%2C%0A%20%20%20%20 %20%20 %20debug_timing%3A%20wasFIDBeforeDCL(firstEntry)%20%3F%20’pre_dcl’%20%3A%20’post_dcl’%2C%0A%20%20%20%20%20%20%20event_time%3A%20firstEntry.startTime%2C%0A%20%20%20%20%20%7D%3B%0A%20%20%20%7D%20else%20if%20(name%20%3D %3D%3D%20’CLS’)%20%7B%0A%20%20%20%20%20const%20largestEntry%20%3D%20getLarges tLayoutShiftEntry(entries)%3B%0A%20%20%20%20%20if%20(largestEntry%20%26%26%20largestEntry.sources)%20%7B%0A%20%20%20%20%20% 20%20const%20largestSource%20%3 D%20getLargestLayoutShiftSource(largestEntry.sources)%3B%0A%20%20%20%20%20%20%20if%20(largestSource)%20%7B%0A%20%20%20%20%20%20% 20%20%20return%20%7B%0A%20 %20%20%20%20%20%20%20%20%20%20debug_target%3A%20getSelector(largestSource.node)%2C%0A%20%20%20%20%20%20%20%20% 20%20%20event_time%3A%20largestEntry.startTim e%2C%0A%20%20%20%20%20%20%20%20%20%7D%3B%0A%20%20%20%20%20%20%20%7D%0A%20% 20% 20%20%20%7D%0A%20%20%20%7D%0A%20%7D%0A%20%2F%2F%20Return%20default%2Fempty%20 params%20in%20case%20there%20are%20no%20entries.%0A%20return%20%7B%0A%20%20%20debug_target%3A%20′(not%20set)’%2C%0A%20%7D% 3B%0A%7D%0A%0Afunction%20sendToAn alytics(%7Bname%2C%20value%2C%20entries%7D)%20%7B%0A%20navigator.sendBeacon(‘%2Fanalytics’%2C%20JSON.stringify(%7B%0A%20%20%20name%2C% 0A%20%20%20value%2C% 0A%20%20%20…getDebugInfo(name%2C%20entries)%0A%20%7D)%3B%0A%7D%0A%0AgetLCP(sendToAnalytics)%3B%0AgetFID(sendToAnalytics)%3B%0AgetCLS(sendToAnalytics) %3B%0A » message= » » highlight= » » provider= »manual »/]
The specific format required to send the data varies by analysis tool, but the above code should be sufficient to get the necessary data regardless of format constraints.
Note that the above code also includes a getSelector() function (not mentioned in the previous sections), which takes a DOM node and returns a CSS selector representing that node and its place in the DOM. It also picks up an optional max length parameter (defaults to 100 characters) in case your parsing tool imposes length restrictions on the data you send it.
Core Web Vitals: How to Get a Report and Visualize Data
Once you’ve collected your Web Vitals metrics, the next step is to aggregate data from all of your users to look for patterns and trends.
As mentioned above, you don’t necessarily need to solve absolutely every problem your users encounter.
You need to address, especially early on, the issues that affect the most users and negatively impact your Core Web Vitals scores.
The Web Vitals Report Tool
If you use the Web Vitals Report tool , it supports reporting on a single debug dimension for each of the Core Web Vitals metrics.
Here is a screenshot of the debug information section of Web Vitals Report, showing data from the Web Vitals Report tool itself:

Using the data above, you can see that whatever is causing the section.Intro element to move has the biggest impact on CLS on this page. So, identifying and fixing the cause of this shift will be the most relevant to improving the score.
Measuring and optimizing Core Web Vitals: essential web signals in a nutshell
Hopefully this article helped you understand how to use existing performance APIs to gain insights for improving each of the Core Web Vitals metrics based on real user interactions. While these techniques are focused on Core Web Vitals, the concepts also apply to optimizing any web performance metric that can be measured in JavaScript.
If you’re just starting to measure your performance and are already a Google Analytics user, the Web Vitals Report tool may be a good place to start, as it already supports reporting debug information for each of the Core Web Vitals metrics.
If you are an analytics vendor looking to improve your products and provide more debugging information to your users, you can draw inspiration from the techniques described, but don’t limit yourself to just the ideas presented here! This article is intended to be applicable to all analytics tools, however, individual analytics tools can (and should) likely capture and surface even more data that is useful for optimizing Core Web Vitals.
Finally, if you think there are any features or information you need to optimize these metrics, please send your feedback to web-vitals-feedback@googlegroups.com .
Want more details on CLS, LCP and FID, and techniques to optimize these SEO signals?