Skip to content

Track Web Vitals

Core Web Vitals measure real user experience: how fast pages appear to load (LCP), how responsive they are to interaction (INP), and how much layout shifts happen during load (CLS). Google Search uses these scores in its ranking algorithm. Tracking them in GA4 means you can correlate page performance with conversion rates and identify which pages need performance work.

MetricWhat it measuresGoodNeeds ImprovementPoor
LCPLargest Contentful Paint — when the main content is visible< 2.5s2.5–4s> 4s
INPInteraction to Next Paint — responsiveness to all interactions< 200ms200–500ms> 500ms
CLSCumulative Layout Shift — visual stability< 0.10.1–0.25> 0.25

Implementation using the web-vitals library

Section titled “Implementation using the web-vitals library”

Google’s web-vitals library is the standard way to measure these metrics. It handles all the complexity of the Intersection Observer, PerformanceObserver, and event timing APIs.

dataLayer.push() web_vital

Pushes each Core Web Vital as it is measured. LCP fires after the page loads, CLS and INP fire when the user navigates away.

(function() {
// Load the web-vitals library from a CDN
// In production, self-host or bundle this
var script = document.createElement('script');
script.src = 'https://unpkg.com/web-vitals@4/dist/web-vitals.iife.js';
script.onload = function() {
function sendVital(metric) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'web_vital',
metric_name: metric.name, // 'LCP', 'INP', 'CLS', 'FCP', 'TTFB'
metric_value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
metric_rating: metric.rating, // 'good', 'needs-improvement', 'poor'
metric_delta: Math.round(metric.delta),
metric_id: metric.id,
navigation_type: metric.navigationType,
page_path: window.location.pathname
});
}
webVitals.onLCP(sendVital);
webVitals.onINP(sendVital);
webVitals.onCLS(sendVital);
webVitals.onFCP(sendVital); // First Contentful Paint
webVitals.onTTFB(sendVital); // Time to First Byte
};
document.head.appendChild(script);
})();
  1. Add the code as a Custom HTML tag in GTM

    • Trigger: Window Loaded (All Pages)
  2. Create a Custom Event Trigger

    • Trigger type: Custom Event
    • Event name: web_vital
  3. Create Data Layer Variables

    • DLV - metric_namemetric_name
    • DLV - metric_valuemetric_value
    • DLV - metric_ratingmetric_rating
    • DLV - navigation_typenavigation_type
  4. Create a GA4 Event Tag

    • Event name: web_vital
    • Parameters:
      • metric_name{{DLV - metric_name}}
      • metric_value{{DLV - metric_value}}
      • metric_rating{{DLV - metric_rating}}
      • navigation_type{{DLV - navigation_type}}
      • page_path{{Page Path}}
    • Trigger: the Custom Event trigger
  5. Register custom dimensions in GA4

    Go to GA4 → Admin → Custom Definitions → Custom Dimensions:

    • metric_name: Event scope
    • metric_value: Event scope (type: Number)
    • metric_rating: Event scope
  6. Test in Preview Mode

    Visit a page. Within a few seconds, web_vital events should start appearing — FCP and TTFB first, then LCP. CLS and INP will only fire when the user navigates away or the tab becomes hidden.

Tag Configuration

GA4 - web_vital

Type
Google Analytics: GA4 Event
Trigger
Custom Event - web_vital
Variables
DLV - metric_nameDLV - metric_valueDLV - metric_rating

Create an Exploration report:

  1. Dimensions: metric_name, page_path, metric_rating
  2. Metric: Event count
  3. Filter: Event name = web_vital

This shows the distribution of ratings (good/needs-improvement/poor) per metric per page.

To find your worst-performing pages:

  • Filter: metric_name = LCP AND metric_rating = poor
  • Dimension: page_path
  • Sort by: Event count descending

If you have the GA4 BigQuery export enabled:

SELECT
params.value.string_value AS page_path,
metric_params.value.string_value AS metric_name,
AVG(value_params.value.int_value) /
CASE metric_params.value.string_value
WHEN 'CLS' THEN 1000.0
ELSE 1.0
END AS avg_metric_value,
COUNTIF(rating_params.value.string_value = 'good') / COUNT(*) AS pct_good,
COUNT(*) AS total_measurements
FROM
`your_project.your_dataset.events_*`,
UNNEST(event_params) AS params,
UNNEST(event_params) AS metric_params,
UNNEST(event_params) AS value_params,
UNNEST(event_params) AS rating_params
WHERE
_TABLE_SUFFIX BETWEEN '20240101' AND '20240131'
AND event_name = 'web_vital'
AND params.key = 'page_path'
AND metric_params.key = 'metric_name'
AND value_params.key = 'metric_value'
AND rating_params.key = 'metric_rating'
GROUP BY 1, 2
ORDER BY pct_good ASC
LIMIT 50
  1. Open GTM Preview and visit a content page
  2. Wait 3-5 seconds — FCP, TTFB, and LCP events should appear in the Summary pane
  3. Navigate to another page (or close the tab) — CLS and INP events should fire on the original page
  4. Check metric_value and metric_rating are sensible (LCP should be in the hundreds of milliseconds for a fast page)
  5. In GA4 DebugView, verify web_vital events appear with parameters

INP replaces FID. The web-vitals library v3+ measures INP (Interaction to Next Paint) instead of FID (First Input Delay). If you have existing FID data in GA4, they are not directly comparable.

CLS fires multiple times. The web-vitals library reports CLS as the user interacts with the page. Each time a new layout shift exceeds the previous maximum CLS value, a new event is fired. The final CLS value (when the user leaves the page) is the authoritative one.

CDN loading adds latency. Loading web-vitals from unpkg.com adds a network request. For production, bundle the library into your site code or self-host it.