PageSpeed 警報容易誤報:改用真實用戶 Core Web Vitals 監測
Your PageSpeed alerts are noise: monitor real-user Core Web Vitals instead
Tobby 建議以 Chrome UX Report(CrUX)的真實用戶數據監測 Core Web Vitals,並以 PageSpeed Insights 的 Lighthouse 實驗室數據作除錯。
Note: I wrote this article with help from an AI assistant. The experiments, numbers and code are mine, and I ran every snippet before publishing.
I once set up "alert me when the PageSpeed score drops" monitoring for 30 sites. Within a day I was getting alerts for sites nobody had touched. So I stopped trusting the alerts and measured the noise instead.
This post covers what I learned: the difference between lab and field data, how to pull both from free Google APIs, the thresholds that actually matter, and an alert rule that doesn't cry wolf.
Lab data vs. field data
PageSpeed Insights (PSI) gives you two very different kinds of numbers on one page:
- Lab data comes from a single Lighthouse run on Google's servers, using a simulated mid-range phone on a throttled connection. It's reproducible-ish and great for debugging, because it tells you why a page is slow (render-blocking scripts, huge images, long tasks).
- Field data comes from the Chrome UX Report (CrUX): real Chrome users visiting the page, aggregated over the last 28 days and reported at the 75th percentile. It tells you what your users actually experience.
They can disagree wildly. When I ran PSI on https://web.dev/ this morning, the lab LCP was about 16 s, while the real-user p75 LCP was 3.1 s. Neither number is "wrong". They measure different things.
Lab scores move a lot on unchanged pages
I ran PSI on the same 30 sites twice, 10 minutes apart, with no deploys in between. The median score change was 2 points, but some scores moved by up to 18 points.
That noise turned straight into false alerts:
| Alert rule (same 30 pages, no real changes) | False alerts |
|---|---|
| Lab score/metric drop vs. the previous run | 19 flags across 3 runs |
| Lab drop vs. a median of earlier runs, confirmed on 2 runs in a row | still 2–7 per run |
| Real-user (CrUX) field data only | 0 |
Field data is a 28-day rolling aggregate, so it doesn't jump between runs. That's exactly what you want for alerting.
The thresholds
Core Web Vitals are judged at the 75th percentile of page loads, split by mobile and desktop (web.dev/articles/vitals):
| Metric | Good | Needs improvement | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | ≤ 2.5 s | 2.5–4 s | > 4 s |
| INP (Interaction to Next Paint) | ≤ 200 ms | 200–500 ms | > 500 ms |
| CLS (Cumulative Layout Shift) | ≤ 0.1 | 0.1–0.25 | > 0.25 |
A page "passes" Core Web Vitals when all three are good at p75. INP replaced FID as the responsiveness metric in March 2024, so if an old dashboard still shows FID, it's out of date.
Option 1: the PageSpeed Insights API (lab + field in one call)
The PSI API returns both the Lighthouse result and the CrUX field data. It works without a key for light use, but a free API key from Google Cloud gives you a proper quota.
// Node 18+ (built-in fetch). Run as an ES module: node psi.mjs
const KEY = process.env.GOOGLE_PSI_API_KEY;
async function psi(url, strategy = "mobile") {
const api = new URL("https://www.googleapis.com/pagespeedonline/v5/runPagespeed");
api.search = new URLSearchParams({ url, strategy, category: "performance", key: KEY });
const data = await (await fetch(api)).json();
const field = data.loadingExperience?.metrics ?? {};
const lab = data.lighthouseResult.audits;
return {
fieldScope: data.loadingExperience?.id, // URL, or origin if the URL lacks traffic
field: {
lcp_ms: field.LARGEST_CONTENTFUL_PAINT_MS?.percentile,
inp_ms: field.INTERACTION_TO_NEXT_PAINT?.percentile,
cls: field.CUMULATIVE_LAYOUT_SHIFT_SCORE?.percentile / 100, // PSI reports CLS x100
},
lab: {
score: Math.round(data.lighthouseResult.categories.performance.score * 100),
lcp_ms: Math.round(lab["largest-contentful-paint"].numericValue),
tbt_ms: Math.round(lab["total-blocking-time"].numericValue),
cls: +lab["cumulative-layout-shift"].numericValue.toFixed(3),
},
};
}
console.log(await psi("https://web.dev/"));
// {
// fieldScope: 'https://web.dev/',
// field: { lcp_ms: 3071, inp_ms: 226, cls: 0.01 },
// lab: { score: 46, lcp_ms: 15989, tbt_ms: 381, cls: 0 }
// } (real response, 23 Oct 2026)
Things that tripped me up:
-
PSI reports field CLS multiplied by 100 (
1means 0.01). Divide before comparing to 0.1. -
Low-traffic URLs have no field data. PSI may fall back to origin-level data (check
loadingExperience.origin_fallback) or return none at all. Label origin data as site-wide so nobody thinks it's about that one page. - A PSI call is slow (often 10–30 s) because it runs Lighthouse. If you only need field data, use the CrUX API directly.
Option 2: the CrUX API (field data only, fast)
The CrUX API answers in well under a second and doesn't run Lighthouse. You need a Google Cloud API key with the Chrome UX Report API enabled.
import json, os, urllib.request, urllib.error
KEY = os.environ["GOOGLE_API_KEY"]
def crux(url, form_factor="PHONE"):
req = urllib.request.Request(
f"https://chromeuxreport.googleapis.com/v1/records:queryRecord?key={KEY}",
data=json.dumps({"url": url, "formFactor": form_factor}).encode(),
headers={"Content-Type": "application/json"},
)
try:
metrics = json.load(urllib.request.urlopen(req))["record"]["metrics"]
except urllib.error.HTTPError as e:
if e.code == 404: # not enough Chrome traffic for this URL
return None
raise
p75 = lambda k: metrics.get(k, {}).get("percentiles", {}).get("p75")
return {"lcp_ms": p75("largest_contentful_paint"),
"inp_ms": p75("interaction_to_next_paint"),
"cls": float(p75("cumulative_layout_shift") or 0)}
print(crux("https://web.dev/"))
# {'lcp_ms': 3071, 'inp_ms': 226, 'cls': 0.01} (real response, 23 Oct 2026)
Two gotchas:
-
Low-traffic pages return 404. Query the origin instead (
{"origin": "https://example.com"}) and label the result as site-wide. -
CLS p75 comes back as a string (
"0.01"), unlike the other metrics. Cast it before comparing.
By the thresholds above, web.dev's own homepage on phones is currently in "needs improvement" for both LCP (3.1 s) and INP (226 ms). Even the people who wrote the guidance find this hard, which is a good reminder to compare against your own history, not just the absolute thresholds.
An alert rule that doesn't cry wolf
Here's what I ended up with:
- After every run, store each page's field p75s (LCP, INP, CLS) per form factor.
- Alert only when:
- Core Web Vitals flip from passing to failing,
- a p75 gets 10% or more worse than the last stored value, or
- a metric's rating drops (good → needs improvement → poor).
- Show lab changes in the report as info only. They're useful clues when a field regression does show up. If you really want lab alerts, compare against the median of earlier runs and require the drop to repeat on two consecutive runs. It cuts the noise a lot, but in my tests it never got to zero.
Run it weekly. CrUX is a 28-day rolling window, so daily runs mostly re-report the same data, and a real regression takes days to show up anyway. If you need faster feedback after a deploy, that's the job of lab tests in CI (Lighthouse CI) or your own RUM with the web-vitals library, not CrUX.
Wrapping up
- Use lab data to debug and field data to alert.
- Judge at p75, per device type, with LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1.
- Watch out for the unit quirks (PSI's CLS × 100, CrUX's string CLS) and for pages without enough traffic.
For running this across whole sitemaps I eventually wrapped it into a small Apify Actor, but the two snippets above are all you need to get started.
How do you monitor performance today: lab, field, or both? I'd like to hear what alert thresholds have worked for you.
來源:DEV Community · dev.to