CrUX & Public Field Data
The Chrome User Experience Report is the dataset your origin is actually assessed on. Everything built in RUM Architecture, Tooling & Self-Hosting gives you resolution CrUX cannot — per-route attribution, custom metrics, sub-second reaction time — but it does not give you the number search systems read. This page covers what CrUX measures, exactly how its population differs from your own beacons, and how to query both the API and the BigQuery export so the two datasets can be reconciled instead of argued about.
The single most useful thing to understand up front: when your RUM and CrUX disagree, neither is wrong. They are measuring different populations with different eligibility rules, and the size of the gap is itself a diagnostic.
What CrUX actually contains
CrUX aggregates field measurements from Chrome users who have opted into usage statistics reporting, are signed in, and meet other eligibility criteria Google does not fully enumerate. The data is published as a 28-day rolling window, updated daily for the API and monthly for the BigQuery export.
| Property | Value | Consequence |
|---|---|---|
| Browser | Chromium-based Chrome only | No Safari, no Firefox — a Safari-heavy origin is under-represented |
| Aggregation window | 28 days, rolling | A fix takes ~28 days to fully appear |
| Granularity | Origin, and URL where volume allows | Low-traffic URLs return no data at all |
| Metrics | LCP, INP, CLS, TTFB, FCP | No custom metrics, no attribution |
| Shape | Histogram in fixed bins + p75 | You can recompute the p75, not the raw values |
| Dimensions | Form factor, effective connection type | Device tier is not available |
The histogram matters more than the headline p75. Because CrUX publishes bin densities, you can compare the shape of your distribution against the public one, not just the summary statistic — which is how you find out whether a gap comes from a slow tail or a shifted centre.
Threshold configuration
CrUX applies the same thresholds everywhere, and an origin passes only when all three assessed metrics are in Good at p75.
| Metric | Good | Needs improvement | Poor | Assessment note |
|---|---|---|---|---|
| LCP | ≤ 2.5 s | 2.5–4.0 s | > 4.0 s | Assessed |
| INP | ≤ 200 ms | 200–500 ms | > 500 ms | Assessed |
| CLS | ≤ 0.1 | 0.1–0.25 | > 0.25 | Assessed |
| TTFB | ≤ 800 ms | 0.8–1.8 s | > 1.8 s | Diagnostic only |
| FCP | ≤ 1.8 s | 1.8–3.0 s | > 3.0 s | Diagnostic only |
Because the window is 28 days rolling, a fix deployed today reaches full effect in the published number four weeks from now, and reaches roughly half effect in two. Teams that judge a fix after a week routinely conclude it did nothing.
Querying the API
The CrUX API returns the histogram and percentiles for an origin or URL, optionally filtered by form factor. It needs an API key and answers in a few hundred milliseconds.
// crux.mjs — fetch the record for an origin, phone form factor.
const KEY = process.env.CRUX_API_KEY;
export async function cruxOrigin(origin, formFactor = 'PHONE') {
const res = await fetch(
`https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${KEY}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
origin,
formFactor,
metrics: ['largest_contentful_paint', 'interaction_to_next_paint',
'cumulative_layout_shift'],
}),
},
);
if (res.status === 404) return null; // not enough traffic for this key
if (!res.ok) throw new Error(`CrUX ${res.status}: ${await res.text()}`);
const { record } = await res.json();
return Object.fromEntries(
Object.entries(record.metrics).map(([name, m]) => [
name,
{
p75: Number(m.percentiles.p75),
good: m.histogram[0].density,
ni: m.histogram[1].density,
poor: m.histogram[2].density,
},
]),
);
}
A 404 is not an error condition to retry — it means the origin or URL did not clear the traffic threshold for that form factor. Handle it as “no data” and fall back to the origin-level record. The full request surface, including URL-level queries and history records, is covered in Using the CrUX API for Origin and URL Data.
Querying the BigQuery export
The monthly BigQuery export is where competitive benchmarking happens: it contains every origin in the dataset, so you can rank yourself against a peer set rather than looking at your own number in isolation.
-- Peer comparison: p75 LCP for a set of origins, latest month, phone.
SELECT
origin,
ROUND(
APPROX_QUANTILES(
(SELECT SUM(bin.start * bin.density) FROM UNNEST(largest_contentful_paint.histogram.bin) AS bin),
100)[OFFSET(75)]
) AS approx_p75_ms
FROM `chrome-ux-report.all.202607`
WHERE origin IN (
'https://example.com',
'https://competitor-a.com',
'https://competitor-b.com'
)
AND form_factor.name = 'phone'
GROUP BY origin
ORDER BY approx_p75_ms;
Scanned bytes are the cost here, so always restrict to the specific monthly table and the origins you need. The full worked pattern, including the materialised tables that make repeated benchmarking affordable, is in Querying the CrUX BigQuery Dataset.
Reconciliation workflow
- Line up the populations. Filter your own RUM to Chrome-only, mobile-only, and the same 28-day window before comparing anything. Most reported “discrepancies” disappear at this step.
- Compare shapes, not summaries. Bucket your beacons into the CrUX bin boundaries and compare densities. A matching shape with a shifted centre is a different story from a matching centre with a fatter tail.
- Check your coverage. If your reporter delivers on 85% of sessions and the missing 15% skews slow (it usually does — see the delivery data in Self-Hosted Beacon Collection), your p75 is optimistic by construction.
- Check the geography mix. CrUX covers every Chrome user who visited; your RUM covers everyone your CDN served. Bot filtering, consent gating and ad blockers all cut differently.
- Account for the window. Compare your 28-day rolling p75, not yesterday’s.
- Accept a residual gap. Five to fifteen percent is normal. A gap above 30% after all of the above is an instrumentation bug worth hunting.
Field-data analysis patterns
- Peer benchmarking by percentile rank, not by absolute value. “We are in the 40th percentile of retail origins for LCP” survives an argument that “we are at 2.9 s” does not.
- Origin vs URL-level divergence points at a template problem: if the origin passes but your highest-traffic URL does not, the aggregate is being carried by lighter pages.
- Form-factor split is the only device dimension CrUX offers. Phone-vs-desktop divergence is the public proxy for the device tiers you can measure properly yourself, as covered in Device & Network Segmentation.
- Month-over-month history from the API history endpoint shows whether a fix landed. Six consecutive fortnightly points is the shortest trend worth reading.
Reading the histogram properly
The published record gives three bin densities per metric, and those three numbers answer questions the p75 cannot.
How close are we to the boundary? An origin at 74% Good is one small improvement away from passing, because the p75 sits just inside the second bin. An origin at 55% Good needs the whole distribution to move. The p75 for both might read “2.6 s”.
Is the problem the centre or the tail? A large Poor share with a healthy Good share means a minority of sessions are having a very bad time — usually one cohort, one route or one third party. A thin Poor share with a large Needs Improvement share means the whole distribution is shifted slightly slow, which is a payload or infrastructure story.
What would a fix be worth? Because the bins are shares of loads, you can estimate the movement needed. Moving from 74% to 76% Good means converting two loads in a hundred, which tells you whether the fix you have in mind is even the right size.
// Reconstruct an approximate p75 from published bin densities.
// The bins are [0, good], [good, poor], [poor, +inf) with fixed boundaries.
export function p75FromHistogram(hist, boundaries) {
let cumulative = 0;
for (let i = 0; i < hist.length; i++) {
const next = cumulative + hist[i].density;
if (next >= 0.75) {
const withinBin = (0.75 - cumulative) / hist[i].density;
const lower = i === 0 ? 0 : boundaries[i - 1];
const upper = boundaries[i] ?? lower * 1.5; // open-ended top bin
return lower + withinBin * (upper - lower);
}
cumulative = next;
}
return boundaries.at(-1);
}
The reconstruction is approximate — it assumes a uniform distribution inside each bin, which is never true — but it is exact enough to bucket your own beacons the same way and compare shapes, which is the point.
Building a peer benchmark that survives review
A benchmark that compares your origin against three competitors you chose is an anecdote. Two changes make it defensible.
Choose the peer set by a rule, not by name. Same industry, same order of magnitude of traffic, same primary market. Write the rule down alongside the numbers so that next quarter’s comparison uses the same set rather than a new one chosen after seeing the results.
Report percentile rank, not distance. “We are at the 62nd percentile of our peer set for LCP on phones” is stable and interpretable. “We are 340 ms behind competitor A” moves every month for reasons that have nothing to do with your site.
| Reporting choice | Survives a hostile read | Why |
|---|---|---|
| Percentile rank within a rule-defined peer set | Yes | Set is reproducible; rank is robust to outliers |
| Distance to the best performer | No | One fast outlier defines your whole narrative |
| Absolute p75 with no comparison | Partly | True but unmotivating; no sense of what is achievable |
| Good-share against the peer median | Yes | Directly connects to the pass or fail assessment |
Where CrUX has no data for a competitor, that absence is itself information: it means the origin does not clear the traffic threshold, which is worth knowing before you benchmark against it.
Using both datasets without confusing anyone
Two field datasets on the same dashboard invite the question of which one is right, and the answer has to be agreed before the numbers are published rather than defended afterwards.
The division that works: CrUX is the scoreboard, your RUM is the diagnostic. Report progress against objectives in CrUX terms, because that is the number that carries external consequences. Investigate, prioritise and verify in your own data, because it is the only one with routes, cohorts and attribution.
That division has three practical implications for how the data is presented.
Label the source on every chart. A panel showing p75 LCP should say which dataset it came from and over what window. Most disagreements are actually two people reading two different panels.
Never mix them in one series. Splicing your own daily numbers onto a CrUX monthly series produces a chart with a discontinuity at the join and no honest interpretation.
Publish the expected gap. Once you have run the reconciliation above, write the residual down — “our RUM reads about 8% faster than CrUX for structural reasons” — and repeat it next to the charts. A known, stable gap stops being a topic of debate; an unexplained one is raised in every review.
| Question | Answer it with | Why |
|---|---|---|
| Are we passing? | CrUX | It is the assessed dataset |
| Which route is worst? | Own RUM | CrUX has no route dimension |
| Did the fix work? | Own RUM first, CrUX after four weeks | Latency of the rolling window |
| How do we compare to peers? | CrUX BigQuery export | Only dataset covering other origins |
| Why is it slow? | Own RUM attribution | CrUX carries no attribution at all |
Failure modes and gotchas
- Comparing a daily own-RUM number to a 28-day CrUX number. These disagree by construction, especially after a deploy. Always compare like windows.
- Expecting URL-level data for every page. Most URLs on most sites never clear the volume threshold. Design your reporting to fall back to origin data gracefully.
- Treating a 404 as an outage. It means insufficient data, and it will happen constantly on URL-level queries.
- Assuming CrUX includes Safari. It does not, and on a media or commerce site with heavy iOS traffic your true population is materially different from the assessed one.
- Reading the p75 without the histogram. The bin densities tell you whether you are close to a band boundary; the p75 alone does not.
- Querying the whole BigQuery export. The
alltable across every month is enormous. Restrict to one monthly table and an explicit origin list, or the cost lands on someone else. - Chasing a gap you have not tried to close. Nine times in ten the discrepancy is population, window or coverage, not measurement error.
CI and reporting cadence
CrUX is too slow to gate a deploy on, and exactly right for a weekly or fortnightly report. Pull the origin record on a schedule, store it next to your own aggregate, and let the two series sit on the same chart.
// weekly-crux-snapshot.mjs — run from cron, store the result, never gate on it.
import { cruxOrigin } from './crux.mjs';
const origins = ['https://example.com'];
const rows = [];
for (const origin of origins) {
for (const formFactor of ['PHONE', 'DESKTOP']) {
const rec = await cruxOrigin(origin, formFactor);
if (!rec) continue;
rows.push({
origin,
formFactor,
capturedAt: new Date().toISOString().slice(0, 10),
lcp: rec.largest_contentful_paint.p75,
inp: rec.interaction_to_next_paint?.p75 ?? null,
cls: rec.cumulative_layout_shift.p75,
lcpGoodShare: rec.largest_contentful_paint.good,
});
}
}
await fetch(process.env.WAREHOUSE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rows),
});
Storing lcpGoodShare alongside the percentile is what lets you answer “are we getting better?” during the four weeks before the p75 has caught up with a fix.
FAQ
Why is my own RUM faster than CrUX?
Almost always population and coverage. Your beacons are missing the sessions that abandoned early or lost their final flush, and they include browsers CrUX excludes. Filter to Chrome, phone and a 28-day window before concluding anything.
How long after a fix does CrUX move?
It is a 28-day rolling window, so a fix reaches roughly half effect after two weeks and full effect after four. Judging a change after seven days will usually show a quarter of the improvement.
Can I get per-route data out of CrUX?
Only for URLs with enough traffic, and only as individual URLs rather than templates. Route-level analysis is a job for your own RUM; CrUX is the origin-level scoreboard.
Is the BigQuery export the same data as the API?
Same source, different shape and cadence. The API is a 28-day rolling window updated daily for one origin or URL; the export is monthly and covers every origin, which is what makes competitive benchmarking possible.
How much traffic does a URL need before CrUX reports it?
Google does not publish the exact threshold, and it differs by form factor. In practice, URLs below roughly a few thousand visits in the 28-day window return no record, and the same URL can appear for phone and be absent for desktop. Design any reporting around origin-level data with URL-level as a bonus.
Can I use CrUX to prove a fix to a stakeholder?
Yes, but set the expectation about latency first. Show the own-RUM improvement immediately, and the CrUX confirmation four weeks later. Presenting only the second one makes a successful fix look like a month of nothing.
Does CrUX include bot or synthetic traffic?
No. It is real Chrome users who meet the eligibility criteria, which is one more reason your own numbers may differ if your bot filtering is imperfect.
Treat the public dataset as one input among three: the lab tells you what changed in a build, your own RUM tells you what real users get and why, and CrUX tells you how that experience is scored by the outside world. A team that checks all three answers most questions in a morning; a team that checks one argues about the other two.
Related
- Querying the CrUX BigQuery Dataset — competitive benchmarking without a runaway query bill.
- Using the CrUX API for Origin and URL Data — the request surface, history records and 404 handling.
- Synthetic vs Field Data Trade-offs — where lab runs fit alongside two field datasets.
- Reconciling Lighthouse Scores with Field Data — the other reconciliation everyone has to do.
- RUM Vendor Comparison — what a paid tool adds over the free public dataset.