Interpreting Core Web Vitals Threshold Tables
A threshold table looks deceptively simple — three rows, three columns, a handful of numbers. The mistakes happen in how those numbers get applied: pulling from a lab run instead of field data, averaging three incompatible units into one meaningless composite, or scoring against a metric Google stopped using. This guide is part of Health Score Reference Tables and walks through exactly how to read the Good/Needs Improvement/Poor bounds and turn a set of raw p75 values into a defensible page-level status.
The Threshold Table
The table below is the same one referenced across Health Score Reference Tables, restated here with the detail that matters most for correct interpretation: units. LCP and INP are measured in milliseconds; CLS is a unitless ratio of viewport-fraction shift distance to impact fraction. Mixing them without normalizing first — the mistake covered in how to weight Core Web Vitals in custom dashboards — is a separate problem from this page's focus, which is simply reading the bounds correctly before any weighting happens.
| Metric | Good | Needs Improvement | Poor | Unit | Data source |
|---|---|---|---|---|---|
| LCP (Largest Contentful Paint) | ≤ 2500 | 2500–4000 | > 4000 | milliseconds | field p75, 28-day window |
| INP (Interaction to Next Paint) | ≤ 200 | 200–500 | > 500 | milliseconds | field p75, 28-day window |
| CLS (Cumulative Layout Shift) | ≤ 0.10 | 0.10–0.25 | > 0.25 | unitless ratio | field p75, 28-day window |
Every bound in this table is a p75 value taken over a rolling window of real visits, not a single test run and not a mean. The 75th-percentile choice is deliberate: it represents the experience of the slowest quarter of real sessions while still discounting rare, extreme outliers that a maximum would capture. If your pipeline is still surfacing average or median values into this table, it is reading the wrong column from the export — the fix belongs in the same normalization step described in calibrating error thresholds for different site sections.
Why Overall Status Is Worst-of-Three, Not an Average
The second table below is the one practitioners skip, and it is the one that actually determines whether a page ships or gets flagged. A page's Core Web Vitals status is the least favorable of its three individual metric statuses — never a blended score.
| LCP status | INP status | CLS status | Overall page status |
|---|---|---|---|
| Good | Good | Good | Good |
| Good | Good | Needs Improvement | Needs Improvement |
| Good | Poor | Good | Poor |
| Needs Improvement | Needs Improvement | Needs Improvement | Needs Improvement |
| Poor | any | any | Poor |
Read the third row carefully: two Good metrics and one Poor metric still produce a Poor page. There is no partial credit. This is the single most common misreading of the reference tables — teams build a 0–100 composite, see a comfortable 70-something, and miss that the page has one metric in outright failure. The worst-of-three rule exists precisely so that a single broken interaction path (a sluggish checkout button, a layout-shifting cookie banner) cannot hide behind two unrelated metrics that happen to be fine.
Classifying a Page Programmatically
The classifier below implements both tables above: per-metric classification against the threshold bounds, then a page-level status via max() over an ordinal enum, which is the cleanest way to express "worst wins" in code.
#!/usr/bin/env python3
# /opt/audit/cwv_thresholds/classify_page.py
"""
Classify a single URL's Core Web Vitals status from p75 field data.
Reads three p75 values (already aggregated over a 28-day window) and
returns the page-level status using the "worst metric wins" rule --
a page is only "Good" if LCP, INP, and CLS are each individually Good.
"""
from dataclasses import dataclass
from enum import IntEnum
# ── 1. status is ordinal so the page-level result can take the max() ────────
class Status(IntEnum):
GOOD = 0
NEEDS_IMPROVEMENT = 1
POOR = 2
@dataclass
class ThresholdRow:
good_max: float # inclusive upper bound for "Good"
ni_max: float # inclusive upper bound for "Needs Improvement"
# ── 2. per-metric thresholds, straight from the CrUX / field definitions ────
THRESHOLDS = {
"lcp_ms": ThresholdRow(good_max=2500, ni_max=4000), # milliseconds
"inp_ms": ThresholdRow(good_max=200, ni_max=500), # milliseconds
"cls": ThresholdRow(good_max=0.10, ni_max=0.25), # unitless ratio
}
def classify_metric(name: str, p75_value: float) -> Status:
"""Classify one metric's p75 value against its Good/NI/Poor bounds."""
t = THRESHOLDS[name]
if p75_value <= t.good_max:
return Status.GOOD
if p75_value <= t.ni_max:
return Status.NEEDS_IMPROVEMENT
return Status.POOR
def classify_page(lcp_ms: float, inp_ms: float, cls: float) -> Status:
"""
Page status = the WORST of the three metric statuses, never an average.
A page with Good LCP, Good CLS, and Poor INP is a Poor page -- not a
"66% good" page. This mirrors the worst-of-three table above exactly.
"""
statuses = [
classify_metric("lcp_ms", lcp_ms),
classify_metric("inp_ms", inp_ms),
classify_metric("cls", cls),
]
return max(statuses) # IntEnum ordering: POOR > NEEDS_IMPROVEMENT > GOOD
if __name__ == "__main__":
# example: fast paint and fast interaction, but a shifting layout
page = {"lcp_ms": 2180, "inp_ms": 210, "cls": 0.08}
result = classify_page(**page)
print(f"lcp={page['lcp_ms']}ms inp={page['inp_ms']}ms cls={page['cls']} "
f"-> {result.name}")
assert result is Status.NEEDS_IMPROVEMENT, "worst-of-three logic broken"
Line by line: section 1 defines Status as an IntEnum specifically so max() produces the correct worst-of-three answer without a manual comparison chain. Section 2 stores each metric's bounds as data rather than hardcoding branches per metric, which keeps THRESHOLDS the single place to update when Google revises a bound. classify_metric does a straightforward inclusive-bound lookup. classify_page is the part that matters most: it collects three independent statuses and takes the maximum, which — because POOR is numerically largest — is exactly the worst-of-three rule from the table above. The __main__ block runs a real example: LCP at 2,180 ms is Good, INP at 210 ms is just over the 200 ms Good bound and lands in Needs Improvement, CLS at 0.08 is Good, so the page-level result is NEEDS_IMPROVEMENT even though two of the three metrics passed cleanly.
Verification and Smoke Test
set -euo pipefail
cd /opt/audit/cwv_thresholds
python3 classify_page.py
Expected output:
lcp=2180ms inp=210ms cls=0.08 -> NEEDS_IMPROVEMENT
The assert inside the script is the actual acceptance gate — it fails loudly if the worst-of-three logic ever regresses to an averaging bug during a refactor. As a second check, confirm the boundary values are inclusive on the Good side, since off-by-one errors here are the most common regression:
python3 -c "
from classify_page import classify_metric, Status
assert classify_metric('lcp_ms', 2500) is Status.GOOD, 'boundary should be Good'
assert classify_metric('lcp_ms', 2501) is Status.NEEDS_IMPROVEMENT, 'boundary should tip over'
print('PASS: boundary values classify correctly')
"
If either assertion fails, the bound comparison was rewritten with a strict < somewhere instead of <=, silently reclassifying every page sitting exactly on a threshold.
Failure Modes
Classifying against lab data instead of field p75
A single Lighthouse run reports LCP, but that number reflects one simulated session on one throttling profile — it is not the p75 field value the threshold table is defined against. Feeding a lab number into classify_page() produces a status that has no relationship to real-user experience.
# diagnostic: confirm the input source column before trusting a classification
python3 -c "
import pandas as pd
df = pd.read_parquet('/opt/audit/cwv_thresholds/input/latest_export.parquet')
print(df['data_source'].value_counts())
"
# fix: filter to field-sourced rows only, or flag lab-sourced rows explicitly
# before they reach classify_page()
Averaging the three metric statuses instead of applying worst-of-three
This is the single most damaging misreading of the table. A composite built from (lcp_score + inp_score + cls_score) / 3 produces a comfortable-looking number that can bury a genuinely Poor metric under two Good ones.
# diagnostic: find pages where an average would hide a Poor metric
python3 -c "
import pandas as pd
df = pd.read_parquet('/opt/audit/cwv_thresholds/input/latest_export.parquet')
masked = df[(df.lcp_status == 'POOR') | (df.inp_status == 'POOR') | (df.cls_status == 'POOR')]
print(f'{len(masked)} pages have a Poor metric hidden by any averaged composite')
"
# fix: replace the averaging step with classify_page()'s max()-based logic
Scoring INP against FID's old thresholds
INP replaced FID as the official responsiveness metric in March 2024. A threshold table or export schema still carrying an fid_ms column, or applying FID's ≤100 ms / >300 ms bounds to an INP value, will misclassify almost every page — INP measures full interaction latency across the page lifecycle, not just input delay, so its numbers run higher even on genuinely responsive pages.
# diagnostic: check whether the export schema still references fid_ms
python3 -c "
import pandas as pd
df = pd.read_parquet('/opt/audit/cwv_thresholds/input/latest_export.parquet')
print('fid_ms' in df.columns, 'inp_ms' in df.columns)
"
# fix: rename/remap the ingest pipeline to populate inp_ms and drop fid_ms
# from any active threshold comparison
FAQ
Why does a page need to pass all three metrics instead of an average score?
Because averaging hides the one metric that is actually hurting users. A page with Good LCP and CLS but Poor INP has a real interaction problem that an averaged score of roughly 67 would mask as merely mediocre. Google's own assessment method classifies a page as Poor if any single metric is Poor, and this reference table's worst-of-three rule mirrors that exactly.
Should I use Lighthouse lab scores instead of CrUX field p75 for this table?
No. Lab tools like Lighthouse run a single simulated session on one device and network profile, while the threshold table is defined against p75 of real-user field data collected over a 28-day window. Lab scores are useful for pre-release regression testing, but classifying a live page's Core Web Vitals status requires field data with sufficient sample size.
Did INP really replace FID, and do I still need to track FID?
Yes. INP replaced FID as the official responsiveness metric in the Core Web Vitals set in March 2024. FID only measured the delay before the first interaction began processing, while INP measures the full latency of the slowest interaction across the entire page lifecycle. Any threshold table or dashboard still keyed on FID is measuring an interaction signal Google no longer factors into its assessment.
Related
- Health Score Reference Tables — parent guide with the full set of reference tables this page draws from
- How to Weight Core Web Vitals in Custom Dashboards — apply weight coefficients once each metric is correctly classified
- Calibrating Error Thresholds for Different Site Sections — adjust bounds and severity per section on top of these tables
- Metric Scoring & Data Normalization — the overview section covering the full scoring architecture