Skip to main content

Core Web Vitals via GTM: Capture LCP, CLS and INP as GA4 Events

Ship the web-vitals library through a custom HTML tag and turn field performance data into GA4 events you can segment.

PageSpeed Insights gives you a score. Search Console gives you a colour-coded bucket for a group of URLs, updated on a lag, based on data you can't slice. Neither can tell you whether INP got worse on mid-range Android after last Tuesday's release, or whether the landing page you paid for has a worse LCP than the organic equivalent.

Sending Core Web Vitals into GA4 as events fixes that. You get real user timings, joined to every dimension GA4 already knows: device, country, browser, landing page, source and medium, logged-in state, whatever else you've registered. Here's the implementation, the reporting it enables, and the two decisions (sampling and when to send) that keep the monitoring from becoming the performance problem.

Field Data Versus Lab Data

Lab data is a synthetic load: one run, one throttled connection, one machine, no cache, no logged-in session, no cookie banner. Lighthouse in CI is genuinely useful because it's deterministic. Change the code, rerun, see if the number moved. That's a regression test and you should have one.

Field data is what your users experienced. Chrome's CrUX dataset is field data, and it's what Google actually uses for the page experience signal, which is the first reason to care about it. But CrUX is aggregated over a rolling 28 day window, only covers Chrome users who opted into reporting, and only exposes origins and URLs with enough traffic to clear a privacy threshold. Anything behind a login is invisible.

The 28 day rolling window is the practical killer. Ship a regression on the 1st and CrUX will show you a partially degraded number for weeks while the window fills, by which point three more releases have gone out and you can't attribute the change to anything. Your own collection tells you the next morning.

Run both. Lab for "did this commit make it worse", field for "what are people actually getting, and which people".

Shipping web-vitals Through Custom HTML

Google's web-vitals library does the measurement. Do not write this yourself. The definitions have changed several times (INP replaced FID, CLS session windowing was revised, LCP handling around bfcache was added), and the library tracks those changes while your hand-rolled PerformanceObserver will not.

Use the attribution build. The standard build gives you a number. The attribution build gives you a number plus the element responsible, which is the difference between knowing LCP is 3.8 seconds and knowing it's the hero image on the pricing page.

Create a Custom HTML tag, trigger it on Initialization so it's listening before anything else runs, and set it to fire once per page:

<script>
(function () {
  window.dataLayer = window.dataLayer || [];

  var s = document.createElement('script');
  s.src = 'https://unpkg.com/web-vitals@4/dist/web-vitals.attribution.iife.js';
  s.async = true;

  s.onload = function () {
    var report = function (metric) {
      var a = metric.attribution || {};
      window.dataLayer.push({
        event: 'web_vitals',
        wv_name: metric.name,                       // LCP | CLS | INP | TTFB
        wv_value: Math.round(
          metric.name === 'CLS' ? metric.value * 1000 : metric.value
        ),
        wv_rating: metric.rating,                   // good | needs-improvement | poor
        wv_target: a.element || a.largestShiftTarget
                   || a.interactionTarget || '(not set)',
        wv_nav_type: metric.navigationType,         // navigate | back-forward-cache | ...
        wv_id: metric.id
      });
    };

    webVitals.onLCP(report);
    webVitals.onCLS(report);
    webVitals.onINP(report);
    webVitals.onTTFB(report);
  };

  document.head.appendChild(s);
})();
</script>

Two things to sort out before this goes live. If you have a Content Security Policy, unpkg.com needs to be in script-src, and most teams would rather self-host the file than add a CDN to the policy. Copy it to your own origin and change the URL. Second, if your engineers can add a dependency, importing web-vitals into the app bundle and pushing to the dataLayer from application code is cleaner than a Custom HTML tag. The GTM route exists for when you can't ship app code this quarter.

The CLS multiplication deserves a note. CLS is a unitless number typically between 0 and 0.5, and rounding 0.08 to an integer gives you zero. Multiplying by 1000 and dividing in the report is the pragmatic fix. If you'd rather keep decimals, GA4 custom metrics do accept floating point values, but the integer approach survives export to more places without surprises.

The Matching GA4 Event Tag

Create dataLayer variables for each key (wv_name, wv_value, wv_rating, wv_target, wv_nav_type, wv_id), then a Custom Event trigger listening for web_vitals, then a GA4 Event tag.

Event name: web_vitals. Keep it one event name with a parameter for which metric, rather than four separate event names. GA4 caps you at 500 distinct event names per property and there's no reason to spend four of them here. It also makes every report a filter rather than four separate queries.

Event parameters:

Do not mark it as a key event. It's diagnostic telemetry, and a key event pollutes conversion reporting and, on some setups, bidding signals downstream.

One caution on metric_target. web-vitals returns a CSS selector path for the responsible element, and on some frameworks those get long and full of generated class hashes. GA4 truncates parameter values at 100 characters. If your selectors are longer than that, truncate deliberately at the source so you control which end survives, rather than letting GA4 cut off the specific part and leave you with div > div > div.

Registering Dimensions and Metrics in GA4

Parameters you don't register are invisible in the GA4 interface. Go to Admin, then Data display, then Custom definitions.

Create event-scoped custom dimensions for metric_name, metric_rating, metric_target, and nav_type. Create a custom metric for metric_value with unit of measurement set to Standard. Skip metric_id as a dimension: it's high cardinality and only useful in raw export, where you don't need registration.

Two limits and one warning. Standard GA4 properties allow 50 event-scoped custom dimensions and 50 custom metrics, so check what's left before spending four of them. The warning is that custom definitions are not retroactive. Data collected before you create the dimension is not annotated, and no backfill exists. Register the definitions the same day you publish the tag, not the week after when you get round to building the report.

If you have BigQuery export switched on, you can skip the custom definitions entirely and read event_params directly. That's the better path for real analysis anyway, mostly because of the percentile problem in the next section.

Questions PageSpeed Insights Cannot Answer

First, an honest limitation. Core Web Vitals are defined at the 75th percentile, and GA4's standard interface gives you sums and averages, not percentiles. Averages hide the tail, which is where all the pain lives. Two workarounds: report on the share of samples rated good using the metric_rating dimension, which is a defensible proxy and reads well to non-technical stakeholders, or compute true percentiles in BigQuery with APPROX_QUANTILES. Use the rating share for dashboards and BigQuery when you're actually diagnosing something.

With that said, here's what opens up. In Explore, build a free-form table with metric_rating as a dimension, Device category as a breakdown, and a filter on metric_name equals INP. Now you can see that desktop is fine and mid-range Android is not, which is invisible in any origin-level score.

Swap the filter to LCP and add Session source / medium as a dimension. Paid landing pages routinely perform worse than the organic equivalents, because they carry more scripts, bigger heroes, and whatever the campaign team added last week. That comparison is a genuinely uncomfortable slide and a very effective one.

Add Landing page + query string and filter to CLS, and you can compare a template before and after a redesign, on the exact date the redesign shipped, with the affected element named in metric_target.

MetricGoodPoorAttribution fieldUsual culprit
LCP2.5s or underover 4.0selementHero image without fetchpriority="high", lazy-loaded above the fold, render-blocking CSS
INP200ms or underover 500msinteractionTargetLong tasks from third-party scripts, heavy re-renders on input
CLS0.1 or underover 0.25largestShiftTargetImages with no width and height, injected banners, late web fonts
TTFB800ms or underover 1800mswaitingDurationSlow origin, redirect chains, uncached HTML

TTFB isn't a Core Web Vital, but collect it anyway. It's the cheapest way to tell whether an LCP problem is a rendering problem or a server problem, and that determines which team owns the ticket.

Sampling Without Losing the Signal

Four events per pageview adds up fast. On a site doing several million pageviews a month, you've just multiplied part of your event volume by four, which matters for BigQuery storage and query cost, for GA4 360 billing, and for anyone downstream paying per event.

Sample. But sample the pageview, not the metric:

var SAMPLE_RATE = 0.1;  // 10% of page loads

// Decide once, at page load, and reuse the decision for every metric.
var sampled = Math.random() < SAMPLE_RATE;
if (!sampled) return;

The reason is correlation. If you roll the dice separately for each metric, you end up with pageviews that have an LCP but no INP, and you can never ask whether slow-loading pages are also the unresponsive ones. Decide once, keep all four or none.

Pick the rate from the smallest slice you actually report on, not from the total. If you want INP for mobile users on the checkout page, and that's a small fraction of traffic, a 1% sample might leave you with a couple of dozen samples a week, which is noise. Work backwards: name the narrowest breakdown you'll defend in a meeting, and set the rate so that slice collects a few hundred samples a week. On a lower traffic site, don't sample at all.

One more consideration: bots and synthetic traffic skew field data badly. If you run uptime checks or Lighthouse in CI against production, exclude them, either by user agent in the tag or by filtering nav_type and known internal IPs downstream.

Sending on visibilitychange, Not Before

The version above sends four separate GA4 hits per page. That's four network requests, four rows, and four chances to fire during a moment the user cares about. Better to buffer and send once, when the page is being hidden:

<script>
(function () {
  var buf = {}, sent = false;
  window.dataLayer = window.dataLayer || [];

  window.__wvCollect = function (metric) {
    buf[metric.name.toLowerCase() + '_value'] = Math.round(
      metric.name === 'CLS' ? metric.value * 1000 : metric.value
    );
    buf[metric.name.toLowerCase() + '_rating'] = metric.rating;
  };

  var flush = function () {
    if (sent || document.visibilityState !== 'hidden') return;
    if (!Object.keys(buf).length) return;
    sent = true;
    buf.event = 'web_vitals_batch';
    window.dataLayer.push(buf);
  };

  addEventListener('visibilitychange', flush, true);
  addEventListener('pagehide', flush, true);   // Safari and bfcache fallback
})();
</script>

Why visibilitychange to hidden rather than unload: unload is unreliable, breaks bfcache eligibility, and is being phased out. The pagehide listener covers older Safari. Together they catch the tab switch, the back navigation, and the app switch on mobile, which is how most sessions actually end.

The trade-off is real. INP is finalized when the page is hidden anyway, so you lose nothing there, but a user who closes the tab in a way that skips both events costs you the whole pageview rather than the metrics that had already fired. In practice the batched approach gives you cleaner data, because one row per pageview with all four metrics is what you want for any correlation question, and one hit instead of four is what you want for cost.

The sent flag matters more than it looks. Without it, a user who switches tabs four times sends four batches, and your averages get weighted toward people with short attention spans.

Keeping the Monitor Lightweight

There's an obvious irony in a performance monitoring script that hurts performance, so check your own work. Load the library async, never render-blocking. Open the Network panel and look at the transferred size of the file you're loading; if it's larger than you expected, you probably grabbed the wrong build. Confirm in the Performance panel that the tag isn't contributing a long task during load.

Then leave it running and look at it monthly rather than daily. Field data is noisy at small windows, and the value of this setup isn't the daily number. It's that when someone asks why mobile conversion dropped after the redesign, you have four weeks of before and after, broken out by device and landing page, sitting in a property you already own.