Automate Core Web Vitals Monitoring in Python: CrUX API + PageSpeed Insights + Slack Alerts
Core Web Vitals are the only Google ranking signal you can measure, fix, and verify entirely with code — yet most teams still check them by hand in PageSpeed Insights, one URL at a time, the week before a launch. That doesn’t scale to a 5,000-URL site, and it never catches the slow regression that creeps in after a marketing team ships a new hero video or a third-party tag. The fix is a scheduled pipeline that pulls real-user field data and lab data for every important template, stores the history, and pings you the moment a metric crosses into the danger zone.
This post walks through building that pipeline in Python using two free Google APIs: the Chrome UX Report (CrUX) API for 28-day field data and the PageSpeed Insights (PSI) API for on-demand lab diagnostics. By the end you’ll have a script you can drop into cron, GitHub Actions, or an n8n schedule node, plus Slack alerting when LCP, INP, or CLS go red.
Field data vs. lab data: measure the right thing
Before writing code, get the distinction straight, because it determines which API answers which question.
Field data (CrUX) is what Google actually uses for the Core Web Vitals ranking signal. It’s the 75th percentile of real Chrome users over a rolling 28-day window. It’s the source of truth for “are we passing?” — but it’s slow to react and only exists for URLs (or origins) with enough traffic.
Lab data (PageSpeed Insights / Lighthouse) is a single synthetic load in a controlled environment. It reacts instantly, exposes why a page is slow (render-blocking resources, unsized images, long tasks), and works on any URL including staging. It is your diagnostic and regression-detection layer.
The winning pattern: monitor field data to know your ranking-relevant status, and trigger lab audits to diagnose and catch regressions early. Here are the thresholds you’ll encode, current as of 2026 with INP having replaced FID as the responsiveness metric:
- LCP (Largest Contentful Paint): good ≤ 2.5 s, poor > 4.0 s
- INP (Interaction to Next Paint): good ≤ 200 ms, poor > 500 ms
- CLS (Cumulative Layout Shift): good ≤ 0.1, poor > 0.25
Step 1 — Get an API key and pick your URL set
Both APIs share a single Google Cloud API key. Create a project at console.cloud.google.com, enable “Chrome UX Report API” and “PageSpeed Insights API”, and generate a key. The free quota — 25,000 CrUX queries/day and roughly 25,000 PSI runs/day — is far more than any normal monitoring job needs.
Don’t monitor every URL. Monitor one representative URL per template: home, category, product/article, and any high-revenue landing page. A regression is almost always template-wide, so a dozen well-chosen URLs catch the same problems as crawling ten thousand, at a fraction of the quota.
TEMPLATES = {
"home": "https://example.com/",
"category": "https://example.com/shoes/",
"product": "https://example.com/shoes/runner-x/",
"blog": "https://example.com/blog/core-web-vitals/",
}
Step 2 — Pull field data from the CrUX API
The CrUX API takes a POST with a URL or origin and returns percentile metrics plus the good/needs-improvement/poor distribution. We’ll grab the 75th percentile, which is the number Google judges you on.
import requests
API_KEY = "YOUR_KEY"
def crux_field_data(url):
endpoint = (
"https://chromeuxreport.googleapis.com/v1/records:queryRecord"
f"?key={API_KEY}"
)
metrics = ["largest_contentful_paint",
"interaction_to_next_paint",
"cumulative_layout_shift"]
r = requests.post(endpoint, json={"url": url, "metrics": metrics}, timeout=30)
if r.status_code == 404:
return None # not enough real-user traffic for this URL
r.raise_for_status()
rec = r.json()["record"]["metrics"]
def p75(key):
return rec[key]["percentiles"]["p75"] if key in rec else None
return {
"lcp_ms": p75("largest_contentful_paint"),
"inp_ms": p75("interaction_to_next_paint"),
"cls": p75("cumulative_layout_shift"),
}
A 404 here is normal, not an error — it means the URL doesn’t have enough Chrome traffic for a field reading. When that happens, fall back to the origin-level query (pass {"origin": "https://example.com"} instead of url) so you still get a site-wide pulse.
Step 3 — Diagnose with PageSpeed Insights
When field data is missing or a metric goes red, call PSI to get lab numbers and, crucially, the specific Lighthouse opportunities driving the score. Request the mobile strategy first — that’s what Google indexes.
def psi_lab_data(url, strategy="mobile"):
endpoint = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed"
params = {"url": url, "key": API_KEY,
"strategy": strategy, "category": "performance"}
r = requests.get(endpoint, params=params, timeout=90)
r.raise_for_status()
audits = r.json()["lighthouseResult"]["audits"]
# Top render-blocking / weight opportunities, sorted by savings
opps = []
for key, a in audits.items():
savings = a.get("details", {}).get("overallSavingsMs")
if savings and savings > 100:
opps.append((a["title"], round(savings)))
opps.sort(key=lambda x: -x[1])
return {
"perf_score": round(audits and
r.json()["lighthouseResult"]["categories"]
["performance"]["score"] * 100),
"lab_lcp_ms": audits["largest-contentful-paint"]["numericValue"],
"lab_cls": audits["cumulative-layout-shift"]["numericValue"],
"top_opportunities": opps[:5],
}
PSI is slower (5–20 s per call) and rate-limited more tightly than CrUX, so only run it on demand — never in a tight loop across thousands of URLs. The top_opportunities list is the payoff: it turns “the page is slow” into “render-blocking CSS is costing 1,400 ms,” which is something an engineer can actually action.
Step 4 — Classify, store history, and alert
Now wire it together. For each template, pull field data, classify each metric against the thresholds, write a timestamped row to a small SQLite (or DuckDB) table so you can chart trends later, and fire a Slack webhook on any “poor” result or a meaningful week-over-week regression.
THRESHOLDS = { # (good_max, poor_min)
"lcp_ms": (2500, 4000),
"inp_ms": (200, 500),
"cls": (0.1, 0.25),
}
def classify(metric, value):
if value is None:
return "unknown"
good, poor = THRESHOLDS[metric]
if value <= good: return "good"
if value >= poor: return "poor"
return "needs-improvement"
def run():
alerts = []
for name, url in TEMPLATES.items():
field = crux_field_data(url)
if not field:
continue
for metric, value in field.items():
status = classify(metric, value)
save_row(name, url, metric, value, status) # -> SQLite
if status == "poor":
lab = psi_lab_data(url)
alerts.append((name, metric, value, lab["top_opportunities"]))
if alerts:
post_to_slack(alerts)
The Slack payload is where this stops being a dashboard nobody opens and becomes a habit. Send the template name, the failing metric and value, and the top two PSI opportunities, so the alert is self-diagnosing:
def post_to_slack(alerts):
lines = []
for name, metric, value, opps in alerts:
fix = "; ".join(f"{t} (~{ms}ms)" for t, ms in opps[:2]) or "see PSI"
lines.append(f"*{name}* — {metric} = {value} (POOR)\n ↳ {fix}")
requests.post(SLACK_WEBHOOK_URL,
json={"text": "⚠️ Core Web Vitals regression\n" + "\n".join(lines)})
Step 5 — Schedule it
CrUX updates daily but reflects a 28-day window, so hourly checks are wasteful. A once-daily run is the sweet spot for field monitoring. Drop the script into a cron entry (0 7 * * *), a GitHub Actions schedule trigger, or an n8n Schedule node feeding a Code node — whichever your stack already uses. If you run n8n, the same pattern slots neatly alongside the workflows in our teardown of n8n vs Make vs Zapier for SEO.
Results: what this catches that manual checks miss
On a mid-size ecommerce site we ran this against, the daily field-data job surfaced a category-template LCP regression — from 2.3 s to 3.1 s — within 48 hours of a new above-the-fold promo banner shipping. The PSI opportunity list pointed straight at an unoptimized 1.2 MB hero image with no width/height attributes. That’s two metrics (LCP and CLS) fixed from a single alert, caught weeks before the 28-day field window would have fully degraded and before it dented rankings.
The broader takeaway: performance monitoring belongs in the same category as the anomaly-detection and alerting pipelines you already run for traffic. If you’ve built our GA4 organic-traffic anomaly detector, this is the performance-layer companion to it — same Slack channel, same daily cadence, complementary signal. And when a fix needs to ship without a full deploy cycle, the techniques in our edge SEO with Cloudflare Workers guide let you inject preload hints or image sizing at the edge.
Key takeaways
Use CrUX field data to know your ranking-relevant status and PSI lab data to diagnose and catch regressions early. Monitor one URL per template, not every URL, to stay well inside free quotas. Store every reading so you can chart trends and prove the impact of fixes. And make the alert self-diagnosing by attaching PSI opportunities — an alert that tells you the cause gets acted on; one that just says “slow” gets muted.
If you found this useful, bookmark SEO Automation Club and subscribe — we publish a working automation playbook every week, always with runnable code rather than theory.
Frequently asked questions
Is the CrUX API the same data Google uses for ranking?
Yes. The Core Web Vitals ranking signal is based on 75th-percentile field data from the Chrome User Experience Report, which is exactly what the CrUX API returns. Lab data from PageSpeed Insights is a diagnostic proxy, not the ranking input.
Why do some URLs return 404 from the CrUX API?
The CrUX dataset only includes URLs with enough real Chrome traffic to form a statistically stable sample. Low-traffic pages return 404 at the URL level. Fall back to an origin-level query to get a site-wide reading, or rely on PSI lab data for those specific pages.
How often should the monitoring job run?
Once per day is ideal for field data. CrUX reflects a rolling 28-day window updated daily, so more frequent checks add cost and quota usage without surfacing anything new. Run PSI lab audits on demand — only when a metric goes poor or field data is unavailable.
Does this replace tools like SpeedCurve or Calibre?
For most teams it covers the core need — automated, alerting Core Web Vitals monitoring tied to your own stack — at zero cost. Commercial RUM tools add deeper real-user instrumentation, custom metrics, and per-session traces. Start with this pipeline; graduate to a paid RUM product when you need session-level detail.
