11 min read

How to Weight Core Web Vitals in Custom Dashboards

Equal-weighting LCP, INP, and CLS in a composite score is the most common mistake in dashboard design for site health pipelines. On content-heavy pages, CLS violations are tolerable because users scroll; on checkout pages, a 50 ms INP regression converts directly to abandoned sessions. Treating all three signals as equivalent hides exactly the friction that matters. This page is part of Designing Custom Health Score Algorithms and shows the exact steps to apply business-aligned weight coefficients in SQL materialized views and Python pipelines.

Core Web Vitals Weighting Pipeline Diagram showing raw CrUX p75 values for LCP, INP, and CLS passing through a normalization step (0–100 scale) and then a weight multiplication step before being summed into a single composite health score. Raw CrUX p75 values LCP / INP / CLS validate Normalize 3-band 0–100 Good/NI/Poor × weights Weight Matrix LCP×0.40 INP×0.40 CLS×0.20 sum Composite Score 0 – 100

Environment Isolation and Dependency Declaration

Pin all dependencies before writing a single line of weight logic. Floating library versions will silently change pd.cut boundary behaviour between runs and corrupt reproducibility across release cycles.

# /opt/audit/cwv_weights — absolute working directory for all scripts
export PYTHONPATH=/opt/audit/cwv_weights
export CWV_WEIGHT_LCP=0.40
export CWV_WEIGHT_INP=0.40
export CWV_WEIGHT_CLS=0.20
export CWV_DB_DSN="postgresql://[email protected]:5432/metrics"
export CWV_WINSORIZE_PERCENTILE=95
# requirements.txt — pin every direct dependency
pandas==2.2.2
numpy==1.26.4
psycopg2-binary==2.9.9
pydantic==2.7.1
scipy==1.13.0
set -euo pipefail
cd /opt/audit/cwv_weights
python3 -m venv .venv
.venv/bin/pip install --quiet -r requirements.txt

The CWV_WEIGHT_* environment variables serve as the single source of truth. The YAML dashboard config and the SQL views both read from them at deploy time — no hardcoded literals anywhere in the pipeline.

Implementation

Step 1 — Normalize raw p75 values to a 0–100 scale

Google's three-band thresholds (Good / Needs Improvement / Poor) are the canonical breakpoints. Mapping to 100/50/0 gives a linear anchor that prevents CLS's narrow decimal range from being crushed by LCP's millisecond magnitude when the two are later multiplied.

#!/usr/bin/env python3
# /opt/audit/cwv_weights/normalize_and_weight.py
"""
Normalize CrUX p75 exports and apply configurable weight coefficients.
Reads CWV_WEIGHT_*, CWV_WINSORIZE_PERCENTILE, and CWV_DB_DSN from environment.
"""

import os
import numpy as np
import pandas as pd
from scipy.stats import percentileofscore

# ── 1. load environment-injected weights ─────────────────────────────────────
W_LCP = float(os.environ["CWV_WEIGHT_LCP"])   # default 0.40
W_INP = float(os.environ["CWV_WEIGHT_INP"])   # default 0.40
W_CLS = float(os.environ["CWV_WEIGHT_CLS"])   # default 0.20
assert abs(W_LCP + W_INP + W_CLS - 1.0) < 1e-9, "Weights must sum to 1.0"

WINSORIZE_PCT = int(os.environ.get("CWV_WINSORIZE_PERCENTILE", 95))

# ── 2. load export ────────────────────────────────────────────────────────────
df = pd.read_parquet("/opt/audit/cwv_weights/input/crux_export.parquet")
# expected columns: page_url, lcp_p75 (ms), inp_p75 (ms), cls_p75 (0–5), device_category

# ── 3. winsorize to suppress single-page extreme outliers ────────────────────
for col in ["lcp_p75", "inp_p75", "cls_p75"]:
    cap = np.percentile(df[col].dropna(), WINSORIZE_PCT)
    df[col] = df[col].clip(upper=cap)

# ── 4. normalize each metric to 0/50/100 using Google thresholds ─────────────
def norm_lcp(v):
    """LCP: Good ≤ 2500 ms, NI ≤ 4000 ms, Poor > 4000 ms."""
    if v <= 2500:  return 100.0
    if v <= 4000:  return 50.0
    return 0.0

def norm_inp(v):
    """INP: Good ≤ 200 ms, NI ≤ 500 ms, Poor > 500 ms."""
    if v <= 200:  return 100.0
    if v <= 500:  return 50.0
    return 0.0

def norm_cls(v):
    """CLS: Good ≤ 0.1, NI ≤ 0.25, Poor > 0.25."""
    if v <= 0.1:  return 100.0
    if v <= 0.25: return 50.0
    return 0.0

df["lcp_norm"] = df["lcp_p75"].map(norm_lcp)
df["inp_norm"] = df["inp_p75"].map(norm_inp)
df["cls_norm"] = df["cls_p75"].map(norm_cls)

# ── 5. apply device-conditional weight overrides ─────────────────────────────
# Mobile devices naturally inflate LCP; shift weight toward INP for mobile.
def weighted_score(row):
    if row.get("device_category") == "phone":
        w_lcp, w_inp, w_cls = 0.35, 0.45, 0.20
    else:
        w_lcp, w_inp, w_cls = W_LCP, W_INP, W_CLS
    return (row["lcp_norm"] * w_lcp
          + row["inp_norm"] * w_inp
          + row["cls_norm"] * w_cls)

df["weighted_score"] = df.apply(weighted_score, axis=1)

# ── 6. write output ───────────────────────────────────────────────────────────
out_path = "/opt/audit/cwv_weights/output/weighted_scores.parquet"
df[["page_url", "device_category", "lcp_norm", "inp_norm",
    "cls_norm", "weighted_score"]].to_parquet(out_path, index=False)

print(f"Wrote {len(df):,} rows → {out_path}")
print(df["weighted_score"].describe().round(1).to_string())

The SQL equivalent, suitable for a read-only materialized view inside your custom health score algorithm database layer:

-- /opt/audit/cwv_weights/sql/weighted_cwv_view.sql
-- Deploy as a materialized view; refresh on each CrUX ingest cycle.
CREATE MATERIALIZED VIEW IF NOT EXISTS cwv_weighted AS
SELECT
  page_url,
  device_category,
  -- normalize LCP (ms) to 0/50/100
  CASE
    WHEN lcp_p75 <= 2500 THEN 100.0
    WHEN lcp_p75 <= 4000 THEN  50.0
    ELSE                        0.0
  END AS lcp_norm,
  -- normalize INP (ms)
  CASE
    WHEN inp_p75 <= 200 THEN 100.0
    WHEN inp_p75 <= 500 THEN  50.0
    ELSE                       0.0
  END AS inp_norm,
  -- normalize CLS (0–5 scale)
  CASE
    WHEN cls_p75 <= 0.10 THEN 100.0
    WHEN cls_p75 <= 0.25 THEN  50.0
    ELSE                        0.0
  END AS cls_norm,
  -- device-conditional composite
  CASE device_category
    WHEN 'phone' THEN
      CASE WHEN lcp_p75 <= 2500 THEN 100.0 WHEN lcp_p75 <= 4000 THEN 50.0 ELSE 0.0 END * 0.35 +
      CASE WHEN inp_p75 <=  200 THEN 100.0 WHEN inp_p75 <=  500 THEN 50.0 ELSE 0.0 END * 0.45 +
      CASE WHEN cls_p75 <= 0.10 THEN 100.0 WHEN cls_p75 <= 0.25 THEN 50.0 ELSE 0.0 END * 0.20
    ELSE
      CASE WHEN lcp_p75 <= 2500 THEN 100.0 WHEN lcp_p75 <= 4000 THEN 50.0 ELSE 0.0 END * 0.40 +
      CASE WHEN inp_p75 <=  200 THEN 100.0 WHEN inp_p75 <=  500 THEN 50.0 ELSE 0.0 END * 0.40 +
      CASE WHEN cls_p75 <= 0.10 THEN 100.0 WHEN cls_p75 <= 0.25 THEN 50.0 ELSE 0.0 END * 0.20
  END AS weighted_score
FROM crux_export
WITH DATA;

CREATE UNIQUE INDEX IF NOT EXISTS cwv_weighted_url_device
  ON cwv_weighted (page_url, device_category);

The dashboard config that overrides these defaults per environment — useful for staging environments that amplify INP sensitivity during pre-release testing:

# /opt/audit/cwv_weights/config/weights.yaml
dashboard:
  cwv_weights:
    default:
      lcp: 0.40
      inp: 0.40
      cls: 0.20
    mobile:
      lcp: 0.35
      inp: 0.45
      cls: 0.20
    staging:
      lcp: 0.35
      inp: 0.50
      cls: 0.15   # pre-release: amplify INP sensitivity
  outlier_cap_percentile: 95
  data_freshness_hours: 24

Verification and Smoke Test

Run the following commands after every deploy of the weighted view. The A/B comparison against your normalizing performance data across device types baseline is the authoritative acceptance gate.

set -euo pipefail

# 1. execute the weighting pipeline
cd /opt/audit/cwv_weights
.venv/bin/python normalize_and_weight.py

# 2. confirm output file exists and is non-empty
test -s output/weighted_scores.parquet && echo "PASS: output written"

# 3. row-count parity — output must match input
INPUT_ROWS=$(.venv/bin/python -c "
import pandas as pd; print(len(pd.read_parquet('input/crux_export.parquet')))
")
OUTPUT_ROWS=$(.venv/bin/python -c "
import pandas as pd; print(len(pd.read_parquet('output/weighted_scores.parquet')))
")
[ "$INPUT_ROWS" -eq "$OUTPUT_ROWS" ] && echo "PASS: row count $OUTPUT_ROWS" \
  || echo "FAIL: input=$INPUT_ROWS output=$OUTPUT_ROWS"

# 4. score range sanity — all values must be in [0, 100]
.venv/bin/python -c "
import pandas as pd
df = pd.read_parquet('output/weighted_scores.parquet')
oob = df[(df.weighted_score < 0) | (df.weighted_score > 100)]
print('PASS: all scores in range') if len(oob) == 0 else print(f'FAIL: {len(oob)} out-of-range scores')
"

# 5. correlation check against RUM session friction events
.venv/bin/python validate_weights.py \
  --weighted  output/weighted_scores.parquet \
  --rum-events input/rum_friction_events.parquet \
  --min-correlation 0.65

Expected output:

PASS: output written
PASS: row count 14,872
PASS: all scores in range
Pearson r = 0.71 (p < 0.001) — PASS

If the correlation falls below 0.65, the weight coefficients do not reflect observed user friction and must be recalibrated before the view is promoted to production.

Failure Modes

Weights sum to more or less than 1.0 after a config edit

The assert in the Python loader catches this at startup. If you bypass Python and edit the SQL view directly, the weighted sum silently drifts out of the 0–100 range.

# diagnostic: check the materialized view max score
psql "$CWV_DB_DSN" -c "
  SELECT MAX(weighted_score), MIN(weighted_score), AVG(weighted_score)
  FROM cwv_weighted;
"
# fix: recompute weights and reload
psql "$CWV_DB_DSN" -c "REFRESH MATERIALIZED VIEW CONCURRENTLY cwv_weighted;"

Outlier pages inflate site-wide averages

A single page with lcp_p75 = 45000 ms (a timed-out crawl artifact) pulls the aggregate down by several points. Winsorization must be applied before normalization, not after.

# diagnostic: find outlier-flagged rows in the output
.venv/bin/python -c "
import pandas as pd, numpy as np
df = pd.read_parquet('output/weighted_scores.parquet')
cap = np.percentile(df.weighted_score.dropna(), 95)
print(df[df.weighted_score < cap * 0.3][['page_url','weighted_score']].head(10))
"
# fix: lower the winsorize cap and re-run
export CWV_WINSORIZE_PERCENTILE=90
.venv/bin/python normalize_and_weight.py

Materialized view becomes stale after a CrUX export cycle

The UNIQUE INDEX on (page_url, device_category) means new URLs added between refreshes do not appear in the dashboard until the next REFRESH MATERIALIZED VIEW runs.

# fix: schedule a refresh immediately after each CrUX ingest job
psql "$CWV_DB_DSN" -c "REFRESH MATERIALIZED VIEW CONCURRENTLY cwv_weighted;"
# confirm last refresh time
psql "$CWV_DB_DSN" -c "
  SELECT schemaname, matviewname, last_refresh
  FROM pg_matviews
  WHERE matviewname = 'cwv_weighted';
"

FAQ

Should I use the same weights for mobile and desktop?

No. Mobile devices have structural LCP disadvantages — slower CPUs and variable network conditions inflate median LCP without reflecting genuine page quality regression. Use device-conditional matrices: phone (LCP=0.35, INP=0.45, CLS=0.20), desktop (LCP=0.45, INP=0.35, CLS=0.20). Segment by device_category before applying weights, not after aggregation.

What happens if I apply weights to raw millisecond values without normalizing?

CLS scores range 0–5 while LCP spans 0–10,000 ms. Raw multiplication makes LCP dominate the composite by three orders of magnitude. Always normalize to a 0–100 scale first — the three-band breakpoint method maps directly to Google's own thresholds and is the same normalization used in the calibrating error thresholds workflow.

How do I handle pages with missing CrUX field data?

Pages with fewer than 250 field data samples are excluded from CrUX exports. Substitute synthetic Lighthouse p75 estimates for those URLs and flag them with a data_source = 'synthetic' column. Apply a 0.7× confidence multiplier to their weighted scores to prevent sparse pages from achieving artificially high composites. This is consistent with the approach described in identifying false positives in automated audits.

How often should weight coefficients be recalibrated?

Recalibrate after any major template change, traffic shift, or Google threshold update. In practice, tie recalibration to your release cycle by running the validate_weights.py correlation check as part of your post-deploy smoke test. Version-control the weights.yaml file so any recalibration is a reviewed commit and rollback is a one-line revert.