PageSpeed Insights API: two sets of numbers in one response, and one of them is moving

The PageSpeed Insights API returns field data from real users and lab data from Lighthouse in the same JSON. Here is which block is which, the two parameters people miss, and where the field half is moving to.

Measurement9 min read2751 views
PageSpeed Insights API: two sets of numbers in one response, and one of them is moving

The PageSpeed Insights API returns two unrelated sets of numbers in one response: field data measured on real Chrome users, and lab data produced by Lighthouse on Google's own machine. Field data tells you what your visitors actually experienced. Lab data tells you what one synthetic run scored. They answer different questions, and only one of them is a number you should compare week to week. The field half is also being moved to a different endpoint.

Read this first

You need a URL you control and a terminal. No Google account is required for a handful of calls, but you will want an API key the moment you put this on a schedule — the keyless pool is shared and it runs out.

This chapter is the second half of a pair. If you have not yet pulled data out of Search Console programmatically, read the Search Console API first: the OAuth-versus-API-key decision and the shape of a Google API response are explained there, and this chapter assumes both. If you only want to know what a slow page costs you in rankings, that is a different question and does page speed affect SEO is the cheaper place to start.

What the PageSpeed Insights API actually returns

One endpoint, one required parameter, and a response with three named blocks. The endpoint is GET https://www.googleapis.com/pagespeedonline/v5/runPagespeed. The only required parameter is url. There is no request body.

Response blockWhat it holdsSourceCompare over time?
loadingExperienceField metrics for the URL you asked aboutCrUX, real Chrome usersYes, but slowly — it is a rolling window
originLoadingExperienceField metrics for the whole originCrUX, real Chrome usersYes — use this when a single URL has too little traffic
lighthouseResultThe lab run: scores, audits, timingsLighthouse, one synthetic loadOnly if the Lighthouse version is unchanged

Two optional parameters do most of the useful work. strategy takes desktop or mobile and defaults to desktop, which is the opposite of what the PageSpeed Insights website shows you first — so a script and a browser screenshot will disagree until you pass it explicitly. category takes performance, accessibility, best-practices or seo, and if you pass none, only performance is run.

Why one request returns two different kinds of number

Field and lab data are collected in ways that cannot be reconciled, so Google puts both in the response and lets you pick. Field data comes from the Chrome User Experience Report: it is aggregated from real page loads on real devices over a rolling 28-day window, and the headline figure is the 75th percentile. Lab data comes from Lighthouse, which loads your page once on a throttled machine and audits it. A single synthetic load can be reproduced exactly; a field number cannot be reproduced at all, because you cannot re-run last month's visitors.

That difference decides which number belongs in which job. Field data is what you report and what you compare month to month. Lab data is what you debug with, because it can point at a specific audit — an image with no dimensions, a render-blocking stylesheet, a font that arrives late.

One thing is worth saying plainly, because it changes what you should build. Google's own documentation now carries this line: "We plan to discontinue including real-world data from the Chrome User Experience Report in this API. We recommend the CrUX API (guide) or the CrUX History API (guide) instead." The field half of PageSpeed Insights is on its way out. If you are writing this integration today, write the field half against the CrUX API and keep PageSpeed Insights for the lab half.

Do it in this order

Four steps. Each one has something you can check before moving on, and the whole sequence takes about fifteen minutes the first time.

  1. Call it once without a key and read the status code. A bare curl against the endpoint is enough. Done when you get JSON back. If you get 429, the keyless pool is exhausted for your IP and you need a key before anything else works.
  2. Get an API key and append it. Create one in the Google Cloud console and add key=… to the request URL. The documentation notes that the key "is safe for embedding in URLs; it doesn't need any encoding". Done when the same call returns 200.
  3. Decide which half you need, then call the matching API. Lab data: PageSpeed Insights, runPagespeed. Field data: CrUX, POST https://chromeuxreport.googleapis.com/v1/records:queryRecord. Done when you have a number you can name — for example the 75th percentile LCP for your origin on phones.
  4. Write the version number into your record. Every lab response carries lighthouseVersion and a fetchTime. Store both. Done when a number from last month can be traced back to the exact tool build that produced it.

Step 4 is the one people skip, and it is the one that makes the rest of the data usable.

The deliverable: one script, both halves

The script below takes a list of URLs, pulls the lab score from PageSpeed Insights and the field metrics from CrUX, and prints one line per URL. It is the smallest version that is still honest about which number came from where.

#!/usr/bin/env python3
# Two endpoints, two datasets. Field data is a 28-day rolling window; lab data is one synthetic run.
import json, sys, urllib.request, urllib.parse

KEY = "YOUR_GOOGLE_API_KEY"          # the same key works for both APIs
PSI = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed"
CRUX = "https://chromeuxreport.googleapis.com/v1/records:queryRecord"

def get(url):
    with urllib.request.urlopen(url, timeout=90) as r:
        return json.loads(r.read())

def post(url, payload):
    req = urllib.request.Request(url, data=json.dumps(payload).encode(),
                                 method="POST", headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=90) as r:
        return json.loads(r.read())

def lab(url, strategy="mobile"):
    q = urllib.parse.urlencode({"url": url, "strategy": strategy, "key": KEY})
    d = get(f"{PSI}?{q}")
    lh = d.get("lighthouseResult", {})
    return lh.get("lighthouseVersion"), (lh.get("categories", {}).get("performance") or {}).get("score")

def field(origin):
    d = post(f"{CRUX}?key={KEY}", {"origin": origin, "formFactor": "PHONE",
                                   "metrics": ["largest_contentful_paint", "cumulative_layout_shift",
                                               "interaction_to_next_paint"]})
    out = {}
    for name, m in (d.get("record", {}).get("metrics") or {}).items():
        out[name] = m.get("percentiles", {}).get("p75")
    return out

if __name__ == "__main__":
    for u in sys.argv[1:]:
        origin = "{0.scheme}://{0.netloc}".format(urllib.parse.urlsplit(u))
        ver, score = lab(u)
        print(f"{u}\n  lab   lighthouse {ver}  performance {score}")
        for name, p75 in field(origin).items():
            print(f"  field {name:26s} p75 {p75}")

Three things in that script are deliberate. The lab call passes strategy=mobile explicitly, because the default is desktop and the website defaults to mobile. The field call asks for the origin, not the page, because a single URL often has too little traffic to appear in the dataset at all. And the field call asks for three metrics by name rather than taking whatever comes back, so a metric being added or retired does not silently change the columns in your report.

Two tables are worth keeping next to it. The first is the metric table, because one of the five is not measured in milliseconds and cannot be averaged. The three "good" thresholds come from Google's own Core Web Vitals definitions, read on 21 September 2026: LCP "should occur within 2.5 seconds", INP "should have a INP of 200 milliseconds or less", and CLS should stay at "0.1 or less" — all measured at the 75th percentile (web.dev/articles/vitals). FCP and TTFB are returned by the same endpoints but are not Core Web Vitals, so there is no official threshold for them and we are not going to invent one.

MetricUnitGoodReported as
LCPmilliseconds≤ 2500 ms75th percentile
INPmilliseconds≤ 200 ms75th percentile
CLSunitless≤ 0.175th percentile
FCPmillisecondsnot a CWV75th percentile
TTFBmillisecondsnot a CWV75th percentile

The second is the two-API comparison, which is the decision most people get wrong on the first attempt.

QuestionPageSpeed InsightsCrUX API
Key required?No, but strongly advisedYes
Field data?Yes, being discontinuedYes, that is the point
Lab data?YesNo
GranularityURL and originURL and origin
HistoryNoYes, CrUX History API
Update cadencePer call, live runDaily, around 04:00 UTC

What goes wrong

You compare two lab scores from different Lighthouse versions. This is the most common bad number in this whole field. Lighthouse ships breaking changes to its scoring, and the API's own release notes record each one — Lighthouse 10, 11, 12 and 13 all landed with notes about changes to the response and the scores. A performance score of 74 today and 68 last quarter may be the same page. The fix is not to stop comparing; it is to store lighthouseVersion and refuse to chart across a version change.

You report a lab score as if it were your users' experience. A lab run is one load on one machine with a defined throttle. It is a great debugging instrument and a poor report. If a stakeholder asks "how fast is the site", the answer is the field number or there is no answer.

You build the field half against the endpoint that is going away. The documentation says the plan is to remove CrUX data from PageSpeed Insights and points at the CrUX API instead. Building against loadingExperience today means a migration later; building against CrUX means the field half is already where it is going.

Common questions

Does the PageSpeed Insights API need a key?

No, but you will want one. The documentation says it "can be used with or without an API key, although a key is recommended for frequent, automated queries". Without a key you are drawing on a shared pool. We hit the end of it on 21 September 2026 and got back 429 with "Quota exceeded for quota metric 'Queries' and limit 'Queries per day' of service 'pagespeedonline.googleapis.com'". That is the whole reason step 1 of the walkthrough checks the status code before anything else.

Why is my script's score different from the score on the website?

Almost always strategy. The API defaults to desktop; the website opens on mobile. Pass strategy=mobile and the two converge. If they still differ, check whether the website is showing you a saved report — its shareable links snapshot a result for up to 30 days.

Can I get Core Web Vitals for a URL with very little traffic?

Usually not, and that is a data property rather than a bug. The field dataset only contains URLs that met a minimum volume of real visits, so a low-traffic page simply will not appear. Ask for the origin instead, or accept that a page nobody visits has no field data to report.

Is the API's field data the same as the CrUX API's?

Same underlying dataset, different delivery. We have not measured the two side by side and will not claim they always agree to the decimal. What is documented is that PageSpeed Insights returns real-world data from the Chrome User Experience Report, and that Google plans to stop including it there and recommends the CrUX API instead.

How do I avoid hammering the API on a schedule?

Cache the response and only re-run a URL when you need to. Lab data changes only when the page changes, and field data updates once a day. A nightly run over a few hundred URLs is fine; a run every five minutes is not, and the quota will tell you so with the same 429 we ran into.

The boundary worth stating out loud: this chapter is about pulling the numbers, not about what to do with them. A performance score does not tell you whether the page earns anything, and we have not measured the relationship between either dataset and your rankings. For that side of the argument, the honest starting point is what 26 homepages actually returned, and the work of turning a measurement into a change that gets published is what QueryWin does.

Part of the QueryWin handbook · Level 2