DEV Community

Hanin Mp
Hanin Mp

Posted on AI-assisted

Does this URL pass Core Web Vitals? Check CrUX field data in Python, with an honest origin fallback

PageSpeed Insights answers "does this one URL pass Core Web Vitals?" well. It answers badly when you have 300 URLs, a template you want to compare, or a weekly report to produce. The CrUX API is the source of that field data, and it is small enough to call from a short script with no dependencies.

The catch is that a few details of the response are easy to get wrong, and one of them silently changes what your report means. This post walks through a standard-library Python script and the traps it handles.

What you get from the CrUX API

The CrUX API returns real-user data from Chrome, aggregated over a rolling 28-day window and refreshed daily. For each metric you get a histogram and a 75th percentile (p75). You can ask for a single url or for the whole origin, and optionally for one formFactor (PHONE, DESKTOP or TABLET).

A page passes a Core Web Vital when its 75th percentile value is in the "good" range. Google defines the good and poor boundaries like this:

Metric Good Poor
LCP 2500 ms or less over 4000 ms
INP 200 ms or less over 500 ms
CLS 0.1 or less over 0.25

Everything between good and poor is "needs improvement". These are the same thresholds for phone and desktop.

Four traps worth handling in code

1. CLS comes back as a string. LCP and INP percentiles are integers. The CLS percentile is a double encoded as a string, so "0.05" <= 0.1 raises a TypeError in Python 3, and comparing it with another string compares text, not numbers. Cast it to float once, in one place.

2. A 404 is normal, not an error. The API answers 404 when it has no record, for example when a URL does not have enough traffic. The same page can be missing at URL level and present at origin level.

3. Falling back to the origin changes the meaning. If you quietly fall back to origin data, a new page with no traffic can look like it "passes" because the rest of the site does. The script keeps the fallback, because origin data is still useful, but it records which level answered in a level column. Treat an origin row as a statement about the site, not about that page.

4. A missing metric is not a pass. A record can contain LCP and CLS but no INP. The script reports pass as true only when all three metrics are present and good, false when any present metric is not good, and leaves it empty when data is missing and nothing has failed.

The script

Save this as crux_cwv.py. It needs Python 3 and an API key with the Chrome UX Report API enabled in Google Cloud.

#!/usr/bin/env python3
"""Check Core Web Vitals field data from the CrUX API, URL first, origin as fallback.

Standard library only. Usage:
    export CRUX_API_KEY=...
    python crux_cwv.py urls.txt --form-factor PHONE > report.csv
"""
import argparse
import csv
import json
import os
import sys
import time
import urllib.error
import urllib.request
from urllib.parse import urlsplit

ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord"

# (good upper bound, poor lower bound) at the 75th percentile.
THRESHOLDS = {
    "largest_contentful_paint": (2500, 4000),
    "interaction_to_next_paint": (200, 500),
    "cumulative_layout_shift": (0.1, 0.25),
}
SHORT = {
    "largest_contentful_paint": "lcp",
    "interaction_to_next_paint": "inp",
    "cumulative_layout_shift": "cls",
}


class NoData(Exception):
    """The API answered 404: no record for this URL or origin."""


def origin_of(url):
    parts = urlsplit(url)
    return f"{parts.scheme}://{parts.netloc}".lower()


def build_body(target, kind, form_factor=None):
    body = {kind: target, "metrics": list(THRESHOLDS)}
    if form_factor:
        body["formFactor"] = form_factor
    return body


def post_json(body, api_key, timeout=20):
    """Real transport. Tests replace this with a fake."""
    req = urllib.request.Request(
        f"{ENDPOINT}?key={api_key}",
        data=json.dumps(body).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.load(resp)
    except urllib.error.HTTPError as err:
        if err.code == 404:
            raise NoData() from err
        raise


def rate(metric, p75):
    good, poor = THRESHOLDS[metric]
    if p75 <= good:
        return "good"
    if p75 > poor:
        return "poor"
    return "needs-improvement"


def read_p75(metric_data):
    """p75 is an int for LCP/INP but a string for CLS, so always cast."""
    p75 = (metric_data.get("percentiles") or {}).get("p75")
    return None if p75 is None else float(p75)


def assess(record):
    """Return {metric: (p75, rating)} and an overall pass flag.

    The overall flag is True only when all three metrics are good, False when
    any is not good, and None when a metric is missing (not enough data).
    """
    metrics = record.get("metrics", {})
    result, missing = {}, False
    for name in THRESHOLDS:
        p75 = read_p75(metrics.get(name, {}))
        if p75 is None:
            result[name] = (None, "no-data")
            missing = True
        else:
            result[name] = (p75, rate(name, p75))
    ratings = [r for _, r in result.values()]
    if any(r in ("needs-improvement", "poor") for r in ratings):
        passed = False
    else:
        passed = None if missing else True
    return result, passed


def fetch(url, api_key, form_factor=None, sender=post_json):
    """Try the URL record first. On 404 fall back to the origin record."""
    try:
        resp = sender(build_body(url, "url", form_factor), api_key)
        return resp["record"], "url"
    except NoData:
        pass
    try:
        resp = sender(build_body(origin_of(url), "origin", form_factor), api_key)
        return resp["record"], "origin"
    except NoData:
        return None, "none"


def period(record):
    cp = record.get("collectionPeriod") or {}
    def fmt(d):
        return f"{d['year']:04d}-{d['month']:02d}-{d['day']:02d}" if d else ""
    return fmt(cp.get("firstDate")), fmt(cp.get("lastDate"))


def rows(urls, api_key, form_factor=None, sender=post_json, pause=0.45):
    for i, url in enumerate(urls):
        if i and pause:
            time.sleep(pause)  # 150 queries per minute per project
        record, level = fetch(url, api_key, form_factor, sender)
        row = {"url": url, "level": level, "form_factor": form_factor or "ALL",
               "first_date": "", "last_date": "", "pass": ""}
        for short in SHORT.values():
            row[f"{short}_p75"] = ""
            row[f"{short}_rating"] = ""
        if record:
            result, passed = assess(record)
            row["first_date"], row["last_date"] = period(record)
            row["pass"] = "" if passed is None else str(passed).lower()
            for name, (p75, rating) in result.items():
                if p75 is not None and p75 == int(p75) and name != "cumulative_layout_shift":
                    p75 = int(p75)  # LCP and INP are whole milliseconds
                row[f"{SHORT[name]}_p75"] = "" if p75 is None else p75
                row[f"{SHORT[name]}_rating"] = rating
        yield row


FIELDS = (["url", "level", "form_factor", "first_date", "last_date"]
          + [f"{s}_{k}" for s in SHORT.values() for k in ("p75", "rating")]
          + ["pass"])


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("urls_file", help="text file, one URL per line")
    ap.add_argument("--form-factor", choices=["PHONE", "DESKTOP", "TABLET"])
    args = ap.parse_args(argv)
    key = os.environ.get("CRUX_API_KEY")
    if not key:
        sys.exit("Set CRUX_API_KEY first.")
    with open(args.urls_file, encoding="utf-8") as fh:
        urls = [ln.strip() for ln in fh if ln.strip() and not ln.startswith("#")]
    writer = csv.DictWriter(sys.stdout, fieldnames=FIELDS)
    writer.writeheader()
    for row in rows(urls, key, args.form_factor):
        writer.writerow(row)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run it

Put one URL per line in urls.txt, then:

export CRUX_API_KEY=your_key_here
python crux_cwv.py urls.txt --form-factor PHONE > report.csv
Enter fullscreen mode Exit fullscreen mode

Leave out --form-factor to get data for all device types combined.

The output has one row per URL. This is example output produced by the script's test fakes with invented numbers, to show the shape of the file. It is not a real site:

url,level,form_factor,first_date,last_date,lcp_p75,lcp_rating,inp_p75,inp_rating,cls_p75,cls_rating,pass
https://shop.example/product,url,ALL,2026-09-01,2026-09-28,2300,good,180,good,0.04,good,true
https://shop.example/checkout,url,ALL,2026-09-01,2026-09-28,3100,needs-improvement,320,needs-improvement,0.12,needs-improvement,false
https://shop.example/new-page,origin,ALL,2026-09-01,2026-09-28,2600,needs-improvement,210,needs-improvement,0.06,good,false
https://nodata.example/,none,ALL,,,,,,,,,
Enter fullscreen mode Exit fullscreen mode

Read the third row carefully: level is origin, so the false describes the site, and the new page itself has no CrUX record yet.

Tests

The tests use a fake sender in place of the network, so they run offline and need no key. They cover the threshold boundaries, the CLS string, the 404 fallback to the origin, a missing metric, and the CSV row shape.

python -m unittest -v test_crux_cwv
Enter fullscreen mode Exit fullscreen mode

All 13 tests pass offline. I did not test the script against the live API, so treat the HTTP part as the least verified piece and try it on one URL first.

Common errors

  • 404 on every URL. Check that the origin is the final, public one: scheme, www or not, and lowercase host. CrUX stores origins in lowercase.
  • Key rejected. The docs say the Google Cloud API key must be provisioned for the Chrome UX Report API, so check that API is enabled for the key's project.
  • Rate limit. The documented limit is 150 queries per minute per project. The script waits 0.45 seconds between URLs, and a URL that falls back to the origin costs two queries, so a very large list may still need a longer pause. The script has no retry or backoff: any HTTP error other than 404 stops the run.
  • A value that looks stale. Data refreshes once a day and runs about two days behind, so repeated calls on the same day return the same numbers.

Limits to keep in mind

  • Query strings and fragments are stripped, so pages that differ only by parameters are grouped together.
  • Single-page apps are attributed to the first page view, not each route change.
  • Chrome on iOS, Android WebView and other Chromium browsers do not contribute data, so CrUX is not your whole audience.
  • The 28-day window means a fix takes weeks to show up fully. Use it for monitoring, not for checking a deploy the same day.
  • This is field data only. It tells you that a page is slow for real users, not why. Use lab tools and your own RUM for diagnosis.

Sources

I am Hanin MP, an SEO specialist in the UAE working on technical SEO, GEO and AEO for client sites across the Gulf and India. I write practical scripts like this one and say plainly what I did and did not test.

Top comments (0)