Health Score Reference Tables
Problem Framing
Scoring pipelines drift apart the moment two engineers hardcode slightly different numbers for "what counts as a slow LCP" or "what score triggers a page." One dashboard shows a section as healthy while another flags it critical, and nobody can say which config is authoritative because the thresholds live in scattered constants across three repositories. This page is the canonical reference: the same Core Web Vitals thresholds, severity tiers, template weights, and score-band labels that every scoring config on this site should cite by version, not reinvent.
Treat it as the source of truth referenced from Metric Scoring & Data Normalization, the parent section covering the broader scoring and normalization approach. When you build or audit a scoring config, copy values from here rather than from memory, and cite the table version in your commit message so a later reviewer can tell whether a threshold changed on purpose.
How the Tables Are Derived
The Core Web Vitals thresholds below are aligned to the Chrome UX Report (CrUX) methodology: each metric is evaluated at the 75th percentile of real-user field data, not lab data, and a page passes a metric only if its p75 value falls in the Good range. Averages and lab-only measurements systematically understate real-world pain because they smooth over the slowest ten to twenty-five percent of visits, which is exactly where SEO and conversion impact concentrates.
The severity, weight, and band tables are derived differently — they are house defaults built from the composite scoring approach in Designing Custom Health Score Algorithms and the percentile-based normalization described in Percentile Normalization Across Metric Distributions. They are safe starting points, not physical constants, and most teams narrow them after a calibration pass against their own traffic mix — see Calibrating Error Thresholds for Different Site Sections for that process.
Table 1 — Core Web Vitals Thresholds
All values are p75 field measurements over a rolling 28-day window, matching the CrUX aggregation period. A page passes Core Web Vitals only when LCP, INP, and CLS are all in the Good range simultaneously — a Good LCP cannot offset a Poor CLS.
| Metric | Good | Needs Improvement | Poor | Unit |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | ≤ 2.5 | 2.5–4.0 | > 4.0 | seconds |
| INP (Interaction to Next Paint) | ≤ 200 | 200–500 | > 500 | milliseconds |
| CLS (Cumulative Layout Shift) | ≤ 0.10 | 0.10–0.25 | > 0.25 | unitless |
| TTFB (Time to First Byte, supplementary) | ≤ 0.8 | 0.8–1.8 | > 1.8 | seconds |
| FCP (First Contentful Paint, supplementary) | ≤ 1.8 | 1.8–3.0 | > 3.0 | seconds |
Table 2 — Severity Tier to Score Band to Action
This table converts a numeric composite score into an operational decision. It is the table an alert-routing config consumes directly — the score band determines which channel a breach routes to and how urgently a human needs to act.
| Severity tier | Score band | Action |
|---|---|---|
| Critical | 0–49 | Page on-call immediately; block the current release; open a P1 remediation ticket |
| Warning | 50–74 | Route to the team Slack channel; open a P2 ticket; remediate within the current sprint |
| Watch | 75–89 | Log for the weekly health review; no page, no ticket unless it persists three cycles |
| Healthy | 90–100 | No action; informational only |
Table 3 — Default Metric Weights by Page Template
Different templates carry different risk profiles, so the composite score should not weight every URL identically. A checkout flow should weight performance more heavily than an about-us page; an article archive should weight indexability more heavily than a one-off landing page. Each row's three weights sum to 1.0.
| Template | Performance weight | Indexability weight | Accessibility weight |
|---|---|---|---|
| Homepage | 0.40 | 0.30 | 0.30 |
| Product / listing (PLP) | 0.45 | 0.35 | 0.20 |
| Article / blog | 0.30 | 0.45 | 0.25 |
| Checkout / transactional | 0.50 | 0.15 | 0.35 |
| Utility / account | 0.25 | 0.25 | 0.50 |
Table 4 — Score Band to Health Label to Recommended Response
This table is the human-facing translation layer — the words that appear on a dashboard tile or a Slack alert instead of a bare number. Keep the labels stable across releases; changing "At Risk" to "Degraded" mid-quarter breaks continuity for anyone comparing month-over-month reports.
| Score band | Health label | Recommended response |
|---|---|---|
| 90–100 | Excellent | Maintain — monitor for regression only |
| 75–89 | Good | Schedule as a backlog improvement item |
| 50–74 | At Risk | Investigate within the current sprint |
| 0–49 | Critical | Immediate remediation; escalate to on-call |
Score Band Scale
The diagram below shows how the 0–100 composite score maps onto the four bands from Table 4, with the severity tier from Table 2 shown underneath each segment.
Worked Example — Consuming the Tables in Python
The snippet below loads Table 3's weights as a dict keyed by template, verifies each row sums to 1.0, then uses Table 4's bands to attach a health label to a composite score.
# /opt/scoring/reference_tables.py
# Requires: Python 3.11+, no third-party dependencies
from __future__ import annotations
# Table 3 — default metric weights by page template
TEMPLATE_WEIGHTS: dict[str, dict[str, float]] = {
"homepage": {"performance": 0.40, "indexability": 0.30, "accessibility": 0.30},
"plp": {"performance": 0.45, "indexability": 0.35, "accessibility": 0.20},
"article": {"performance": 0.30, "indexability": 0.45, "accessibility": 0.25},
"checkout": {"performance": 0.50, "indexability": 0.15, "accessibility": 0.35},
"utility": {"performance": 0.25, "indexability": 0.25, "accessibility": 0.50},
}
# Table 4 — score band to health label, checked low-to-high, lower bound inclusive
SCORE_BANDS: list[tuple[int, int, str]] = [
(0, 49, "Critical"),
(50, 74, "At Risk"),
(75, 89, "Good"),
(90, 100, "Excellent"),
]
def label_for_score(score: float) -> str:
"""Map a 0-100 composite score to its Table 4 health label."""
rounded = round(score)
for low, high, label in SCORE_BANDS:
if low <= rounded <= high:
return label
raise ValueError(f"Score {score} is outside the 0-100 range")
def composite_score(template: str, sub_scores: dict[str, float]) -> float:
"""
Compute a weighted composite score using Table 3's per-template weights.
sub_scores keys must match performance/indexability/accessibility, each 0-100.
"""
weights = TEMPLATE_WEIGHTS[template]
return sum(sub_scores[k] * weights[k] for k in weights)
if __name__ == "__main__":
example = composite_score(
"checkout",
{"performance": 62.0, "indexability": 95.0, "accessibility": 88.0},
)
print(f"Checkout composite score: {example:.1f} -> {label_for_score(example)}")
Verification
Run these checks whenever the reference tables are edited, before deploying the change to a scoring config:
- Assert every template row in Table 3 sums to
1.0within floating-point tolerance:
for template, weights in TEMPLATE_WEIGHTS.items():
total = sum(weights.values())
assert abs(total - 1.0) < 1e-9, f"{template} weights sum to {total}, expected 1.0"
print("All template weight rows sum to 1.0")
- Confirm the score bands in Table 2 and Table 4 are contiguous with no gaps or overlaps: the upper bound of one band plus one must equal the lower bound of the next (
49 + 1 == 50,74 + 1 == 75,89 + 1 == 90). - Spot-check one page per template against Table 1: pull its p75 LCP, INP, and CLS from the last 28 days and confirm the pass/fail classification matches what your dashboard reports.
- Diff the checked-in reference table file against the previous Git revision and require a reviewer sign-off on any threshold or weight change — these tables are load-bearing for every downstream alert.
Troubleshooting
Thresholds feel stale — pages that "should" pass are showing Poor
Root cause: the Core Web Vitals thresholds in Table 1 are periodically revised (INP replaced FID in March 2024, for instance). A scoring config pinned to an old threshold snapshot silently drifts from the current CrUX methodology.
# Compare the checked-in threshold file's last-modified commit against today
git log -1 --format="%ai" -- content/metric-scoring-data-normalization/health-score-reference-tables/index.md
Fix: review Table 1 against current CrUX guidance at least twice a year and bump dateModified when a threshold changes, so downstream configs know to re-sync.
Unit confusion between milliseconds and seconds
Root cause: LCP, TTFB, and FCP are listed in seconds in Table 1 for readability, but most RUM SDKs emit milliseconds. A config that copies 2.5 as a millisecond threshold instead of 2500 will flag nearly every page as failing.
# Sanity-check a threshold constant before deploying it
lcp_threshold_ms = 2.5 # WRONG — this is seconds, not milliseconds
assert lcp_threshold_ms > 100, "LCP threshold looks like seconds, not milliseconds"
Fix: standardize on milliseconds internally for all latency metrics and convert Table 1's second-denominated values (2.5 -> 2500) at the point of ingestion, with a unit test that catches the reversed conversion.
A score sits on a band boundary and different dashboards disagree
Root cause: one dashboard treats bounds as inclusive on the upper end, another on the lower end, so a score of 50 is classified "Critical" in one tool and "At Risk" in another.
Fix: standardize on the convention documented under the score-band scale above — lower bound inclusive — and update both dashboards' comparison operators (>= versus >) to match.
FAQ
Why do the tables list TTFB and FCP thresholds when only LCP, INP, and CLS are official Core Web Vitals?
TTFB and FCP are supplementary field metrics that explain regressions in the three official Core Web Vitals. A slow TTFB is frequently the root cause of a failing LCP, so scoring pipelines track it even though it does not factor into the CWV pass/fail assessment itself.
How often should the default weight-by-template table be recalibrated?
Review it quarterly and immediately after any redesign that changes a template's conversion path, such as moving checkout to a single-page flow. Recalibration should be a versioned commit to the weights file, not a silent runtime edit.
What happens if a page's composite score sits exactly on a band boundary?
Boundaries are inclusive on the lower bound of each band, so a score of exactly 50 falls in the At Risk band rather than Critical. Document this convention in code comments since off-by-one errors at boundaries are a common source of disagreement between dashboards.
Can I use these reference tables directly, or must I calibrate per site?
The Core Web Vitals thresholds are safe to use as-is since they come from the CrUX methodology. The severity tiers, template weights, and band labels are defaults meant as a starting point — most teams narrow them after establishing baseline metrics for their own domain.
Related
- Metric Scoring & Data Normalization — parent section covering the full scoring and normalization approach
- Interpreting Core Web Vitals Threshold Tables — how to read Table 1 correctly, including p75 and the all-metrics-must-pass rule
- Designing Custom Health Score Algorithms — the composite scoring approach these weight defaults feed into
- Percentile Normalization Across Metric Distributions — how raw metrics become the 0-100 sub-scores this page's tables consume
- Calibrating Error Thresholds for Different Site Sections — narrowing these defaults per site section