Building a Web Vitals Grafana Dashboard
You have field metrics landing in a time-series store but no single view that tells the team whether the site is inside budget right now. This guide builds that view: a three-panel Core Web Vitals dashboard — LCP, INP, and CLS, each plotted as a trailing P75 with its own budget threshold line — as a concrete companion to Visualizing Budget Trends with Grafana. The goal is a board where one glance answers "are we breaching, and on which metric?" without reading an axis.
The three metrics behave differently and cannot share a query template. LCP and INP are durations in milliseconds; CLS is a unitless score. Each needs its own unit, its own budget value, and its own percentile expression — INP in particular is the slowest interaction per session, so the P75 must be computed over a per-session maximum, not over every event.
Panel & Query Plan
| Panel | Unit | Budget line (P75) | Aggregation | Field column |
|---|---|---|---|---|
| LCP P75 | ms | 2500 (high-end mobile, 4G) | percentile_cont(0.75) over events |
value WHERE metric='LCP' |
| INP P75 | ms | 200 (high-end mobile, 4G) | P75 over per-session max | max(value) per session |
| CLS P75 | score | 0.10 | percentile_cont(0.75) over events |
value WHERE metric='CLS' |
The budget lines are the high-end-mobile P75 "good" thresholds; if your audience skews mid-range mobile on Fast 3G, raise the LCP line toward 3500 ms per your own field data. Keep each panel pinned to a single device class so the threshold line means one thing.
Diagnostic Steps
Before building panels, confirm the store actually holds the metrics and routes you expect.
psql "$PERF_DB_URL" -c "SELECT metric, count(*) FROM web_vitals WHERE ts > now() - interval '1 day' GROUP BY metric;"
Expected output — all three metrics present with non-trivial counts:
metric | count
--------+-------
CLS | 41822
INP | 39104
LCP | 42551
If INP counts are far lower than LCP, your beacon is dropping sessions with no interaction; that is correct, but verify it is intentional and not a sampling bug from your RUM pipeline.
Implementation
Import the dashboard as JSON so it is reproducible. The block below is the three-panel model; each panel carries its own threshold step at its budget value.
{
"title": "Core Web Vitals — Budget",
"panels": [
{
"title": "LCP P75", "type": "timeseries", "gridPos": { "h": 8, "w": 8, "x": 0, "y": 0 },
"fieldConfig": { "defaults": { "unit": "ms",
"custom": { "thresholdsStyle": { "mode": "line+area" } },
"thresholds": { "mode": "absolute", "steps": [
{ "value": null, "color": "green" }, { "value": 2500, "color": "red" } ] } } },
"targets": [ { "refId": "A", "format": "time_series",
"rawSql": "SELECT time_bucket('1 hour', ts) AS time, percentile_cont(0.75) WITHIN GROUP (ORDER BY value) AS lcp_p75 FROM web_vitals WHERE metric='LCP' AND $__timeFilter(ts) GROUP BY 1 ORDER BY 1" } ]
},
{
"title": "INP P75", "type": "timeseries", "gridPos": { "h": 8, "w": 8, "x": 8, "y": 0 },
"fieldConfig": { "defaults": { "unit": "ms",
"custom": { "thresholdsStyle": { "mode": "line+area" } },
"thresholds": { "mode": "absolute", "steps": [
{ "value": null, "color": "green" }, { "value": 200, "color": "red" } ] } } },
"targets": [ { "refId": "A", "format": "time_series",
"rawSql": "SELECT time_bucket('1 hour', ts) AS time, percentile_cont(0.75) WITHIN GROUP (ORDER BY s.inp_max) AS inp_p75 FROM (SELECT session_id, time_bucket('1 hour', ts) AS ts, max(value) AS inp_max FROM web_vitals WHERE metric='INP' AND $__timeFilter(ts) GROUP BY 1,2) s GROUP BY 1 ORDER BY 1" } ]
},
{
"title": "CLS P75", "type": "timeseries", "gridPos": { "h": 8, "w": 8, "x": 16, "y": 0 },
"fieldConfig": { "defaults": { "unit": "none", "decimals": 3,
"custom": { "thresholdsStyle": { "mode": "line+area" } },
"thresholds": { "mode": "absolute", "steps": [
{ "value": null, "color": "green" }, { "value": 0.1, "color": "red" } ] } } },
"targets": [ { "refId": "A", "format": "time_series",
"rawSql": "SELECT time_bucket('1 hour', ts) AS time, percentile_cont(0.75) WITHIN GROUP (ORDER BY value) AS cls_p75 FROM web_vitals WHERE metric='CLS' AND $__timeFilter(ts) GROUP BY 1 ORDER BY 1" } ]
}
]
}
On a Prometheus store, replace each rawSql target with the histogram-quantile equivalent — for example the INP panel becomes:
histogram_quantile(0.75, sum by (le) (rate(web_vitals_inp_bucket[1h])))
The INP query is the one to get right: the inner subquery reduces each session to its slowest interaction first, then the outer query takes the P75 across sessions. Computing the P75 directly over raw interaction events understates INP because most interactions are fast and the metric is defined on the worst one per visit.
CI Gating Assertion
The dashboard visualizes field data; the build is still gated by the lab assertion. Keep the panel budget lines and the gate in lockstep with the same numbers, expressed here as a lighthouserc assertion block:
{
"ci": {
"assert": {
"assertions": {
"metric-lcp": ["error", { "maxNumericValue": 2500 }],
"metric-inp": ["error", { "maxNumericValue": 200 }],
"metric-cls": ["error", { "maxNumericValue": 0.1 }]
}
}
}
}
Verification
Confirm the threshold rendering works by forcing a breach. Insert a synthetic over-budget LCP sample and reload the board:
psql "$PERF_DB_URL" -c "INSERT INTO web_vitals(ts, metric, route, session_id, value, source) VALUES (now(), 'LCP', '/checkout', 'verify-1', 4200, 'test');"
On reload, the LCP panel's latest bucket P75 should rise above the dashed 2500 ms line and the area above the line should shade red. If the line is absent, Show thresholds is still set to Off on that panel — switch it to lines and regions. Delete the test row (WHERE source='test') once confirmed.
Frequently Asked Questions
Why compute INP over a per-session maximum instead of all events?
INP is defined as the worst interaction latency a user experiences in a visit, so the field metric is the per-session maximum. If you take the P75 across every individual interaction event, the many fast clicks dominate and the number is far lower than the real INP. The inner subquery in the panel reduces each session to its slowest interaction before the percentile is taken.
What budget values should the threshold lines use?
The panel uses the high-end-mobile 4G "good" P75 thresholds: 2500 ms LCP, 200 ms INP, 0.10 CLS. If your real audience is mostly mid-range mobile on Fast 3G, derive your own lines from field P75 over a trailing 28-day window rather than copying these. See Percentile-Based Threshold Tuning.