12 min read

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

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.

Health Score Band Scale A horizontal 0 to 100 scale divided into four score bands — 0 to 49 Critical, 50 to 74 At Risk, 75 to 89 Good, and 90 to 100 Excellent — each labeled with its severity tier and health label. Composite Health Score (0–100) 0–49 · Critical Immediate remediation 50–74 · At Risk Fix this sprint 75–89 · Good Backlog item 90–100 Excellent 0 50 75 90 100 Lower bound of each band is inclusive — a score of exactly 50 is At Risk, not Critical

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:

  1. Assert every template row in Table 3 sums to 1.0 within 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")
  1. 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).
  2. 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.
  3. 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.