Tracking LCP field data with the CrUX API

Lab tests tell you a regression could hurt users; the Chrome UX Report (CrUX) tells you whether it did. CrUX aggregates real Chrome page loads into a public dataset, and its API returns the p75 Largest Contentful Paint your actual audience experienced — across every device and network in your traffic, not a single simulated profile. This guide, part of Monitoring & Regression for Media Delivery under CDN & Edge Media Delivery, shows how to query chromeuxreport.googleapis.com for p75 field LCP by origin and by URL, track it over time, alert on regressions, and reconcile the field number against the lab budgets that gate your builds.

Prerequisite checklist

Field LCP versus lab LCP

The two numbers answer different questions and will not match, by design.

Lab LCP is one page load under a fixed throttle — reproducible, instant, gate-able. Field LCP is the 75th percentile across every real load in a rolling 28-day window — authoritative about user experience, but aggregated and days-late. CrUX buckets each metric into good / needs improvement / poor and reports the p75; for LCP the Core Web Vitals threshold is p75 ≤ 2.5 s for “good.” Because it is a 28-day rolling aggregate, a deploy’s effect bleeds in gradually: the day after a fix, only one of twenty-eight days reflects it, so the p75 barely moves. That lag is the defining property of field data and the reason CrUX alerts, but never gates.

Field p75 LCP trend crossing the 2.5s threshold A line chart of daily p75 LCP over time. The line sits near 2.0s, then after a regression deploy climbs gradually over several days past a dashed 2.5s Core Web Vitals threshold, triggering an alert, then declines slowly after a fix because the 28-day window dilutes the change. LCP x time (days) — 28-day rolling window 2.5s CWV threshold (good ≤ 2.5s) regression deploy alert: p75 > 2.5s fix deploy slow recovery — window dilutes the fix

Exact solution

Step 1 — Query with curl

The CrUX API is a single POST to the queryRecord endpoint. The JSON body selects what to measure and for whom. Query an origin for a whole-site rollup, or a url for a single page.

# Query p75 LCP for a specific URL on phones.
# The API key goes in the query string; the selection is in the JSON body.
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
        "url": "https://media-delivery.com/products/",
        "formFactor": "PHONE",
        "metrics": ["largest_contentful_paint"]
      }'
# Origin-level query: aggregates ALL pages on the origin. Use this when a
# single URL lacks the traffic for its own record. Note "origin" not "url",
# and DROP the path — an origin is scheme + host only.
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
        "origin": "https://media-delivery.com",
        "formFactor": "PHONE",
        "metrics": ["largest_contentful_paint"]
      }'

The response nests the p75 under record.metrics.largest_contentful_paint:

{
  "record": {
    "key": { "formFactor": "PHONE", "url": "https://media-delivery.com/products/" },
    "metrics": {
      "largest_contentful_paint": {
        // The three CWV buckets and the fraction of loads in each.
        "histogram": [
          { "start": 0,    "end": 2500, "density": 0.78 },  // "good"
          { "start": 2500, "end": 4000, "density": 0.15 },  // "needs improvement"
          { "start": 4000,             "density": 0.07 }    // "poor"
        ],
        // p75 in MILLISECONDS — the number you gate the alert on.
        "percentiles": { "p75": 2320 }
      }
    },
    // The rolling window this record covers — 28 days, lagging "today".
    "collectionPeriod": { "firstDate": {"year":2026,"month":6,"day":6}, "lastDate": {"year":2026,"month":7,"day":3} }
  }
}

Step 2 — Parse p75 in Node.js

// crux.mjs — fetch p75 field LCP for a url or origin + form factor.
const ENDPOINT = 'https://chromeuxreport.googleapis.com/v1/records:queryRecord';

export async function fetchFieldLcp({ url, origin, formFactor = 'PHONE' }) {
  // Send EITHER url OR origin — never both. url is a single page;
  // origin aggregates the whole site and needs far less traffic to resolve.
  const body = { formFactor, metrics: ['largest_contentful_paint'] };
  if (url) body.url = url; else body.origin = origin;

  const res = await fetch(`${ENDPOINT}?key=${process.env.CRUX_API_KEY}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });

  // 404 = "CrUX data not found": the url/origin lacks sufficient traffic
  // for this form factor. Fall back to an origin query or a coarser slice.
  if (res.status === 404) return { insufficientData: true };
  if (!res.ok) throw new Error(`CrUX ${res.status}: ${await res.text()}`);

  const { record } = await res.json();
  const lcp = record.metrics.largest_contentful_paint;
  return {
    p75Ms: lcp.percentiles.p75,
    goodDensity: lcp.histogram[0].density,      // fraction of loads ≤ 2.5s
    period: record.collectionPeriod,            // the 28-day window covered
    insufficientData: false,
  };
}

Step 3 — Store the series and alert on regression

Poll on a schedule (daily is the useful cadence — CrUX refreshes daily but each record still summarizes 28 days), append to a time series, and compare against both the absolute threshold and the previous window.

// crux-monitor.mjs — run daily from cron or a scheduled CI job.
import { fetchFieldLcp } from './crux.mjs';
import { appendSeries, lastValue } from './store.mjs';  // your persistence layer

const TARGETS = [
  { url: 'https://media-delivery.com/products/', formFactor: 'PHONE' },
  { url: 'https://media-delivery.com/', formFactor: 'PHONE' },
];

for (const t of TARGETS) {
  let m = await fetchFieldLcp(t);

  // If the URL is too low-traffic, fall back to the origin rollup so the
  // series never silently goes blank.
  if (m.insufficientData) {
    m = await fetchFieldLcp({ origin: 'https://media-delivery.com', formFactor: t.formFactor });
  }
  if (m.insufficientData) continue;

  const prev = await lastValue(t);              // previous stored p75
  await appendSeries(t, { date: new Date().toISOString().slice(0, 10), p75: m.p75Ms });

  // Alert on (a) crossing the CWV "good" threshold, or (b) a meaningful
  // week-over-week worsening even while still under threshold.
  const crossedThreshold = m.p75Ms > 2500;
  const worsened = prev && (m.p75Ms - prev) / prev > 0.10;
  if (crossedThreshold || worsened) {
    await notify(`Field LCP regression on ${t.url}: p75 ${m.p75Ms}ms `
      + `(prev ${prev ?? 'n/a'}ms, good-density ${(m.goodDensity * 100).toFixed(0)}%)`);
  }
}

async function notify(msg) { /* POST to Slack / PagerDuty / open an issue */ }

Finding the LCP image in the field

CrUX itself does not name the LCP element. To attribute a field LCP regression to a specific image, pair CrUX with PageSpeed Insights (PSI), which runs both a lab audit and returns CrUX field data in one call — its lab largest-contentful-paint-element audit names the element, which you can then match against the field trend:

# PSI returns loadingExperience (CrUX field data) AND a lighthouseResult
# whose "largest-contentful-paint-element" audit names the LCP node.
curl -s "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://media-delivery.com/products/&strategy=mobile&key=$PSI_API_KEY" \
  | jq '.lighthouseResult.audits["largest-contentful-paint-element"].details.items[0]'

Verification steps

1. Confirm the API key and endpoint work

# A 200 with a "record" object confirms the key and API are live.
# A 403 means the Chrome UX Report API is not enabled on the project.
curl -s -o /dev/null -w '%{http_code}\n' \
  "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"origin":"https://media-delivery.com","metrics":["largest_contentful_paint"]}'

2. Distinguish “insufficient data” from a real error

# 404 with "CrUX data not found" = not enough traffic for that url/formFactor,
# NOT a bug. Retry at origin scope or without formFactor to widen the sample.
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://media-delivery.com/a-low-traffic-page/","metrics":["largest_contentful_paint"]}' \
  | jq '.error.message // .record.metrics.largest_contentful_paint.percentiles.p75'

3. Confirm the field number is in the right ballpark versus lab

Field p75 LCP is normally higher than a desktop lab LCP because the field mixes in slow devices and networks. If your lab budget passes at 1.8 s but field p75 is 3.9 s, the lab profile is too optimistic for your audience — recalibrate the lab throttle toward the field device mix rather than celebrating the green build.

Expected relationships:

Signal Typical relationship If violated
Field p75 vs desktop lab LCP Field usually higher (real device mix) Lab profile too optimistic; add a slower throttle
PHONE p75 vs DESKTOP p75 PHONE usually higher Investigate mobile-specific image sizing
Day-over-day p75 change Small (window is 28-day averaged) A large jump implies a traffic-mix shift, not a deploy
good-density Should track p75 (higher density = lower p75) Divergence hints at a bimodal audience

Common mistakes and fixes

1. Expecting a deploy to move p75 tomorrow

Anti-pattern: shipping a fix, checking CrUX the next day, and concluding it didn’t work because p75 barely moved.

Effect: the 28-day rolling window means one good day is averaged with 27 older days. The fix is invisible for days and only fully reflected after a full window.

Fix: wait a full 28-day window before judging field impact, and watch a self-hosted RUM beacon for the leading edge — RUM shows today’s loads immediately, while CrUX confirms the durable p75 later. Treat CrUX as the slow, authoritative backstop, not a same-day signal.

2. Confusing “insufficient data” with an outage

Anti-pattern: treating a 404 CrUX data not found as a broken pipeline and alerting on it.

Effect: low-traffic URLs and rare form factors legitimately return 404 because CrUX only publishes records above a privacy/traffic threshold. Alerting on it creates noise and masks real regressions.

Fix: handle 404 as a distinct “insufficient data” state (as in Step 2’s code) and fall back to an origin query, which aggregates enough traffic to resolve. Only alert on an actual p75 value crossing threshold.

3. Querying url when you need origin (and vice versa)

Anti-pattern: sending a full path in the origin field, or sending a bare host in the url field.

Effect: an origin value must be scheme + host with no path; including a path returns no record. A url value must be the exact page URL and needs far more traffic to resolve than the origin does.

Fix: use origin: "https://media-delivery.com" (no path) for a site-wide rollup, and url: "https://media-delivery.com/products/" for a specific page — and never send both keys in one request. Start at origin scope, and only drill into url for pages with enough traffic.

4. Ignoring the sufficient-data threshold when picking targets

Anti-pattern: building a per-URL dashboard for every page including brand-new and low-traffic URLs.

Effect: most of those URLs never accumulate enough traffic to appear in CrUX, so the dashboard is mostly blank cells that look like failures.

Fix: track field LCP at url scope only for your handful of high-traffic templates; cover everything else with the origin rollup. Match the granularity to where CrUX actually has data.

5. Comparing field p75 directly to a lab pass/fail

Anti-pattern: treating a green lab budget as proof that field LCP is fine.

Effect: lab and field measure different populations. A desktop lab run can pass at 1.8 s while mobile field p75 sits at 3.5 s — the users are fine in the lab and failing in reality.

Fix: hold the two as separate signals. Gate merges on lab (fast, deterministic), alarm on CrUX field (slow, authoritative), and when they diverge, recalibrate the lab profile toward your real device distribution. The reconciliation of these two numbers is the core discipline described in Monitoring & Regression for Media Delivery.

Tradeoff: CrUX is free but coarse — 28-day windows, p75 only, no per-element attribution, and a traffic floor that hides small pages. A self-hosted RUM beacon gives you same-day data, custom percentiles, and the LCP element, but costs engineering time and storage. Most teams run both: RUM for immediacy and drill-down, CrUX as the neutral, comparable-across-sites backstop.