Using the CrUX API for Origin and URL Data

The CrUX API is the fastest way to see the number your origin is actually assessed on: a 28-day rolling window, updated daily, for an origin or a specific URL. It is also the endpoint most often used wrongly — hammered on every dashboard load, queried for URLs that will never have data, and read as though it were a live metric. This guide sits under CrUX & Public Field Data and covers both endpoints, the failure modes that are normal rather than exceptional, and how to store the results so they sit usefully beside your own RUM.

Prerequisites

  • A Google Cloud API key with the Chrome UX Report API enabled.
  • A server-side place to call it from. The key must not ship to the browser.
  • Somewhere to store daily snapshots — the API gives you a rolling window, not a history you can query later.

How to use it

Step 1 — Query a record

// crux.mjs — one origin or URL, one form factor, the metrics you need.
const ENDPOINT = 'https://chromeuxreport.googleapis.com/v1/records:queryRecord';

export async function queryRecord({ origin, url, formFactor = 'PHONE', metrics }) {
  const body = {
    ...(url ? { url } : { origin }),
    formFactor,                                   // PHONE | TABLET | DESKTOP
    metrics: metrics ?? [
      'largest_contentful_paint',
      'interaction_to_next_paint',
      'cumulative_layout_shift',
      'experimental_time_to_first_byte',
    ],
  };

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

  if (res.status === 404) return null;            // not enough data — expected
  if (res.status === 429) throw new RateLimitError();
  if (!res.ok) throw new Error(`CrUX ${res.status}: ${await res.text()}`);
  return (await res.json()).record;
}

Omitting formFactor returns data aggregated across all of them, which is rarely what you want: the phone and desktop populations behave so differently that a blended number describes neither.

Step 2 — Treat 404 as data, not as an error

A 404 means the origin or URL did not clear the traffic threshold for that combination of parameters. It is the single most common response for URL-level queries and it must not be retried or logged as a failure.

// Fall back from URL to origin, then record the absence explicitly.
export async function bestAvailable(url, formFactor) {
  const byUrl = await queryRecord({ url, formFactor });
  if (byUrl) return { grain: 'url', record: byUrl };

  const origin = new URL(url).origin;
  const byOrigin = await queryRecord({ origin, formFactor });
  if (byOrigin) return { grain: 'origin', record: byOrigin };

  return { grain: 'none', record: null };         // report it; do not retry
}

Storing the grain alongside the value matters downstream: a chart that silently mixes URL-level and origin-level numbers looks continuous and is not.

Step 3 — Read the histogram, not only the percentile

// Normalise a record into flat rows worth storing.
export function flattenRecord(record, meta) {
  const rows = [];
  for (const [metric, m] of Object.entries(record.metrics ?? {})) {
    const bins = m.histogram ?? [];
    rows.push({
      ...meta,
      metric,
      p75: Number(m.percentiles?.p75 ?? NaN),
      goodShare: Number(bins[0]?.density ?? 0),
      niShare: Number(bins[1]?.density ?? 0),
      poorShare: Number(bins[2]?.density ?? 0),
      collectionStart: record.collectionPeriod?.firstDate,
      collectionEnd: record.collectionPeriod?.lastDate,
    });
  }
  return rows;
}

The three shares are the useful part. They tell you how close the origin is to a band boundary and they move before the percentile does, which is what lets you show progress during the weeks the p75 has not caught up.

records:queryHistoryRecord returns roughly six months of fortnightly points in one call, which is far better than reconstructing a trend from daily snapshots you started collecting last month.

const HISTORY = 'https://chromeuxreport.googleapis.com/v1/records:queryHistoryRecord';

export async function queryHistory({ origin, formFactor = 'PHONE' }) {
  const res = await fetch(`${HISTORY}?key=${process.env.CRUX_API_KEY}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ origin, formFactor, metrics: ['largest_contentful_paint'] }),
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`CrUX history ${res.status}`);
  const { record } = await res.json();
  // timeseries arrays are parallel to collectionPeriods.
  const periods = record.collectionPeriods ?? [];
  const series = record.metrics.largest_contentful_paint.percentilesTimeseries.p75s ?? [];
  return periods.map((p, i) => ({
    periodEnd: p.lastDate,
    p75: series[i] == null ? null : Number(series[i]),
  }));
}

Nulls in the series are normal: they mark fortnights where the origin did not clear the threshold. Plot them as gaps, never as zeros.

Step 5 — Respect the quota and cache aggressively

The data changes once a day at most, so a fresh call per dashboard view is wasted quota and wasted latency. Fetch on a schedule, store, and read from your own table.

// snapshot.mjs — run daily from cron; the dashboard never calls the API.
const TARGETS = [
  { origin: 'https://www.example.com' },
  { url: 'https://www.example.com/products/popular-item' },
];

export async function dailySnapshot(store) {
  const captured = new Date().toISOString().slice(0, 10);
  for (const target of TARGETS) {
    for (const formFactor of ['PHONE', 'DESKTOP']) {
      try {
        const record = await queryRecord({ ...target, formFactor });
        if (!record) { await store.recordAbsence({ ...target, formFactor, captured }); continue; }
        await store.write(flattenRecord(record, { ...target, formFactor, captured }));
      } catch (err) {
        if (err instanceof RateLimitError) { await sleep(60_000); continue; }
        throw err;
      }
      await sleep(250);                            // stay well inside the quota
    }
  }
}
Why a daily snapshot is worth keepingThe fix landed in week 2 and your own data showed it immediately. CrUX takes four weeks to fully reflect it because of the rolling window — which is what to tell stakeholders before they ask.Why a daily snapshot is worth keepingStored p75 LCP against the fix that shipped in week 20 ms1,250 ms2,500 ms3,750 ms5,000 msGood 2.5 sw1w2w3w4w5w6w7w8CrUX p75 (28-day window)Own RUM p75 (daily)
The fix landed in week 2 and your own data showed it immediately. CrUX takes four weeks to fully reflect it because of the rolling window — which is what to tell stakeholders before they ask.
Which grain answered the queryThree quarters of queries for rarely visited URLs return nothing at all. That is the dataset working as designed, and it is why the query list should be derived from traffic rather than from a sitemap.Which grain answered the queryOutcome of 240 scheduled API calls over a monthTop 5 landing pages92%8%Template exemplars54%46%Rarely visited URLs6%19%75%URL-level recordOrigin fallbackNo data (404)
Three quarters of queries for rarely visited URLs return nothing at all. That is the dataset working as designed, and it is why the query list should be derived from traffic rather than from a sitemap.

Fitting it alongside your own data

Storing CrUX snapshots next to your own aggregates is what makes both useful, provided three conventions are followed.

Never join them into one series. They have different windows, different populations and different update cadences. Two lines on one chart, clearly labelled, is correct; one line spliced from both is not.

Record the collection period with every row. A CrUX value captured today describes the 28 days ending a few days ago. Charting it against today’s own-RUM number without that offset makes fixes look slower than they were.

Store absences. A URL that dropped below the threshold produces no row, and a chart with a gap invites the question “did the pipeline break?”. An explicit absence record answers it.

With those in place the pairing answers the question neither dataset answers alone: your own data says the fix worked, and the CrUX series says when it will count. That is the sentence that manages expectations in a programme review, and it is worth having ready before the first review rather than after it.

Verifying it works

  1. The stored p75 matches what public tooling reports for the same origin and form factor on the same day.
  2. 404s are recorded as absences, and their rate is stable rather than growing.
  3. Quota usage is well under the limit, checked in the API console after the first week of scheduled runs.
  4. The API key is server-side only. Grep the built bundle; a key in client code will be used by someone else within days.
  5. History and daily snapshots agree where they overlap, allowing for the fortnightly grain of the history endpoint.

Which URLs are worth querying individually

URL-level records exist only above a traffic threshold, so a list of every page is mostly a list of 404s. Three categories are worth the calls.

Your highest-traffic landing pages. Usually the home page and a handful of category or campaign destinations. These reliably have data, and they are where search traffic arrives.

One representative URL per template. A popular product page stands in for the template. It will not represent the rarely visited pages — a popular item is cached everywhere and its images are warm — but it gives a URL-level reading to compare against the origin.

Any page under active optimisation. During a programme it is worth knowing whether the specific page moved, not just the origin.

Everything else should read the origin record. A useful pattern is to derive the query list from your own RUM: take the top URLs by session count, cap the list at twenty, and refresh it monthly. That keeps the schedule short, the quota comfortable, and the 404 rate low — and it automatically follows traffic as it shifts, which a hand-maintained list never does.

How long a fix takes to appear in the published numberThe rolling window mixes four weeks of data, so a fix appears gradually. Judging it after seven days shows a quarter of the improvement and reads as failure.How long a fix takes to appear in the published numberShare of the true improvement reflected, by week after the fixWeek 124%Week 251%Week 376%Week 497%0%25%50%75%100%
The rolling window mixes four weeks of data, so a fix appears gradually. Judging it after seven days shows a quarter of the improvement and reads as failure.

Edge cases and gotchas

  • Origin strings are exact. Scheme included, no trailing slash, and www is a different origin from the apex.
  • A 404 is not retryable. Retrying wastes quota and will never succeed until the traffic threshold is met.
  • Rolling window, not a daily value. The number you fetch today describes 28 days, so day-to-day movement is heavily smoothed by construction.
  • Form factor changes availability. An origin present for phone may be absent for tablet; handle each independently.
  • The connection-type dimension is coarse. It exists, and it is far less useful than the device tiers you can measure yourself, per Device & Network Segmentation.
  • Experimental metric names change. TTFB has moved between experimental and stable naming; pin the field names you read and handle their absence.

FAQ

How fresh is the data?

Updated daily, describing a 28-day rolling window. A change shipped today reaches roughly half effect in the published number after two weeks and full effect after four.

Why do I get a 404 for a page that clearly has traffic?

URL-level records need substantially more traffic than most pages receive, and the threshold differs per form factor. Fall back to the origin record.

Can I call the API from the browser?

You should not — the key would be public. Call it from a scheduled server-side job and serve the stored result to any front end that needs it.

How many origins can I monitor?

Enough for a peer set and your own properties comfortably. For a large peer benchmark the BigQuery export is the right tool, as covered in Querying the CrUX BigQuery Dataset.