|

Automate Core Web Vitals Monitoring with the CrUX API, Python, and n8n

Core Web Vitals stopped being a “nice to have” the moment Google folded field data into its page experience signals. But here is the trap most teams fall into: they open PageSpeed Insights, see a green score on their homepage, and assume the whole site is healthy. Lab scores are a single synthetic run on one URL. The ranking signal Google actually uses is the 75th-percentile field data collected from real Chrome users, aggregated in the Chrome UX Report (CrUX). That data shifts daily as your traffic mix, third-party scripts, and template changes move through production — and nobody is watching it.

This guide walks through a monitoring pipeline that pulls p75 field metrics from the free CrUX API for a list of URLs, detects regressions against yesterday’s numbers, and fires a Slack alert before a slow template quietly drags a whole section out of the “Good” bucket. Everything here is working code plus an n8n workflow you can self-host. If you have automated Googlebot crawl monitoring already, this slots into the same daily cron.

Why field data beats lab data for monitoring

Lab tools like Lighthouse run a controlled, throttled test on a single device profile. They are perfect for debugging a specific page, but useless for catching a regression that only shows up on, say, mid-tier Android phones in a slow-3G region. CrUX solves that by reporting the actual distribution of real-user experiences over a rolling 28-day window. Three metrics matter today:

  • LCP (Largest Contentful Paint) — render time of the largest visible element. “Good” is a p75 of 2,500 ms or less.
  • INP (Interaction to Next Paint) — responsiveness to user input; this replaced FID in March 2024. “Good” is a p75 of 200 ms or less.
  • CLS (Cumulative Layout Shift) — visual stability. “Good” is a p75 of 0.1 or less.

The key word is p75: Google takes the value below which 75% of experiences fall. A site can have a fast median and still fail if the slow tail is fat. That is exactly the kind of degradation a daily monitor catches and a one-off PageSpeed check misses.

The architecture

The pipeline has four moving parts, and none of them require paid infrastructure:

  1. A URL list — either origin-level or specific page URLs you care about.
  2. A Python collector that calls the CrUX API and writes today’s p75 values to a small SQLite table.
  3. A regression check that compares today vs. the last stored run and flags any metric that crossed a threshold or worsened by more than a set margin.
  4. An n8n workflow that runs the script on a schedule and pushes alerts to Slack.

SQLite keeps the historical series so you can later feed it into a self-updating SEO dashboard without re-querying the API.

Step 1 — Get a CrUX API key

The CrUX API is free with a generous quota. Create a project in the Google Cloud Console, enable the “Chrome UX Report API”, and generate an API key. A single record query looks like this:

curl -s -X POST \
  "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/", "formFactor": "PHONE"}'

Two things to know. First, you can query either "url" (a specific page) or "origin" (the whole domain) — origin queries have far broader coverage because they aggregate every page. Second, if a URL has too little traffic, the API returns a 404; that is not an error in your code, it just means CrUX has no field data for that page, and you should fall back to the origin.

Step 2 — The Python collector

This script reads a list of URLs, queries the API for both PHONE and DESKTOP, extracts the p75 for each metric, and stores it. The p75 lives at record.metrics.<metric>.percentiles.p75 in the response.

import os, sqlite3, datetime, requests

CRUX_KEY = os.environ["CRUX_KEY"]
ENDPOINT = f"https://chromeuxreport.googleapis.com/v1/records:queryRecord?key={CRUX_KEY}"
METRICS = ["largest_contentful_paint",
           "interaction_to_next_paint",
           "cumulative_layout_shift"]

def query(target, form_factor="PHONE"):
    key = "url" if target.rstrip("/").count("/") > 2 else "origin"
    body = {key: target, "formFactor": form_factor}
    r = requests.post(ENDPOINT, json=body, timeout=30)
    if r.status_code == 404:
        return None  # no field data for this target
    r.raise_for_status()
    return r.json()["record"]["metrics"]

def p75(metrics, name):
    try:
        return float(metrics[name]["percentiles"]["p75"])
    except (KeyError, TypeError):
        return None

def collect(urls, db="cwv.db"):
    con = sqlite3.connect(db)
    con.execute("""CREATE TABLE IF NOT EXISTS cwv(
        day TEXT, url TEXT, form TEXT,
        lcp REAL, inp REAL, cls REAL,
        PRIMARY KEY(day, url, form))""")
    today = datetime.date.today().isoformat()
    for url in urls:
        for form in ("PHONE", "DESKTOP"):
            m = query(url, form)
            if not m:
                continue
            con.execute(
                "INSERT OR REPLACE INTO cwv VALUES (?,?,?,?,?,?)",
                (today, url, form,
                 p75(m, "largest_contentful_paint"),
                 p75(m, "interaction_to_next_paint"),
                 p75(m, "cumulative_layout_shift")))
    con.commit(); con.close()

Note the timeout and the 404 handling — both are the difference between a script that runs unattended for months and one that pages you at 6 a.m. because a low-traffic URL had no data.

Step 3 — Detect regressions, not just failures

A naive monitor only alerts when a metric crosses into “Poor”. That is too late — by then your rankings may already be affected. A better approach flags two conditions: a threshold breach (a metric leaving the “Good” band) and a material worsening (a metric degrading by more than a tolerance versus the last run, even if still technically “Good”). The tolerance suppresses noise from CrUX’s normal day-to-day jitter.

THRESHOLDS = {"lcp": 2500, "inp": 200, "cls": 0.1}
# relative worsening that counts as a regression
TOLERANCE = {"lcp": 0.10, "inp": 0.10, "cls": 0.15}

def regressions(db="cwv.db"):
    con = sqlite3.connect(db)
    rows = con.execute("""
        SELECT url, form, lcp, inp, cls FROM cwv
        WHERE day = (SELECT MAX(day) FROM cwv)""").fetchall()
    prev = {(r[0], r[1]): r[2:] for r in con.execute("""
        SELECT url, form, lcp, inp, cls FROM cwv
        WHERE day = (SELECT MAX(day) FROM cwv
                     WHERE day < (SELECT MAX(day) FROM cwv))""")}
    alerts = []
    for url, form, *today in rows:
        before = prev.get((url, form))
        for i, metric in enumerate(("lcp", "inp", "cls")):
            now = today[i]
            if now is None:
                continue
            if now > THRESHOLDS[metric]:
                alerts.append(f"{url} [{form}] {metric.upper()} "
                              f"= {now} breaches Good ({THRESHOLDS[metric]})")
            elif before and before[i]:
                delta = (now - before[i]) / before[i]
                if delta > TOLERANCE[metric]:
                    alerts.append(f"{url} [{form}] {metric.upper()} "
                                  f"up {delta:.0%} ({before[i]}->{now})")
    con.close()
    return alerts

Returning a plain list of human-readable strings keeps the alerting layer dumb: n8n just checks whether the list is non-empty and forwards it.

Step 4 — Wire it into n8n with Slack alerts

You do not need a custom server for the schedule. In n8n, a four-node workflow handles the whole loop. The Schedule Trigger fires once a day (CrUX data refreshes daily, so more often is wasted quota). An Execute Command node runs your collector script. A second Execute Command runs the regression check and returns its stdout. Finally, an IF node checks whether the output is non-empty and routes truthy results to a Slack node that posts the alert list to your team channel.

If you would rather keep logic out of shell scripts, swap the Execute Command nodes for an HTTP Request node hitting the CrUX endpoint directly and a Code node for the p75 extraction — n8n’s Code node runs JavaScript or Python and can hold the same comparison logic. Either way, the alert wiring is identical to what you would build for technical SEO monitoring with Slack alerts, so you can reuse the same Slack credentials and channel.

What to expect once it is running

The first week is mostly about calibrating the tolerance. Set it too tight and CrUX’s natural variance will spam you; too loose and you will miss slow drifts. In practice, a 10% relative tolerance for LCP and INP and 15% for CLS catches real template regressions — a hero image that lost its width/height attributes, a new third-party tag that blocks the main thread, a font swap that pushes layout — while ignoring background noise.

The payoff is lead time. Because CrUX is a rolling 28-day window, a regression you ship today only fully expresses in field data over the following weeks. A daily monitor catches the upward drift in week one, while you can still correlate it to the deploy that caused it. Without the monitor, you usually find out when Search Console’s Core Web Vitals report flips a URL group to “Needs improvement” — a month and several deploys too late to know what changed.

Takeaways

Treat Core Web Vitals as a monitored production metric, not a quarterly audit. Pull p75 field data from the free CrUX API, store the series so you can see trends, and alert on worsening rather than only on outright failure. Wire the whole thing into the same n8n-plus-Slack pattern you already use for crawl and rank monitoring, and you get a near-zero-cost early-warning system that ties performance regressions back to the deploys that caused them.

Want more build-it-yourself automation playbooks? Bookmark SEO Automation Club and check back each week — every post ships with working code and a real workflow, not generic checklists. If technical monitoring is your focus, the daily Googlebot crawl monitor pairs naturally with this pipeline on the same cron.

Frequently asked questions

Is the CrUX API really free?

Yes. The Chrome UX Report API is free to use with a Google Cloud API key, subject to a standard per-minute quota that is far more than a daily monitoring job needs. You only need billing enabled for high-volume use cases like the BigQuery CrUX dataset, not for the per-record API used here.

Why does the API return a 404 for some of my URLs?

A 404 means CrUX has insufficient real-user data for that specific URL to report on. This is common for low-traffic or new pages. Fall back to an origin query, which aggregates the whole domain and almost always has coverage.

Should I monitor at the URL or origin level?

Both have a place. Origin-level monitoring gives you a stable, high-coverage signal for the whole site and is the best early-warning tripwire. URL-level monitoring is for your highest-value templates and landing pages, where you want to catch a regression that an origin average would dilute. The collector above queries whichever you pass it.

How is this different from just checking PageSpeed Insights?

PageSpeed Insights runs a single synthetic Lighthouse test plus a CrUX summary for one URL on demand. This pipeline automates the field-data half across many URLs, stores history, and alerts on change over time — turning a manual spot-check into continuous monitoring.



Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *