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. Before you touch a panel, it helps to see the whole path the numbers travel, because a wrong threshold line usually traces back to a mismatch upstream: a beacon that samples differently than you assume, or a store that mixes device classes into one series.

How the Data Reaches the Panel

Every value on this dashboard starts as a web-vitals measurement in a real browser, is posted to an ingest endpoint, lands in a web_vitals table, and is aggregated at query time by Grafana. If any hop mixes device classes or drops sessions, the P75 you plot is measuring a different population than the budget line assumes. Keep one device class per data source, or add a device_class column and filter on it in every panel query. The pipeline that produces these rows is covered in Custom Performance Beacons & RUM; this page assumes the rows already exist and focuses on turning them into a budget board.

Web Vitals data flow Four stages: browser beacon, ingest worker, TimescaleDB web_vitals table, and Grafana P75 panels connected by arrows. From browser to budget board Browser web-vitals beacon Ingest worker sendBeacon to DB TimescaleDB web_vitals table Grafana P75 panels Aggregation happens at query time in Grafana, not at write time so keep one device class per data source or filter on device_class
Each P75 on the board is only as trustworthy as the population feeding it; pin one device class per series.

Panel and 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 (high-end mobile, 4G) percentile_cont(0.75) over events value WHERE metric='CLS'

The budget lines are the high-end-mobile 4G P75 "good" thresholds. If your audience skews mid-range mobile on Fast 3G, raise the LCP line toward 3500 ms and re-derive INP and CLS from your own field data rather than copying lab defaults. Keep each panel pinned to a single device class so the threshold line means one thing; a line that averages a fast desktop cohort with a slow mobile one is a number no engineer can act on. The exact placement of each line is a calibration decision, not a copy-paste — Percentile-Based Threshold Tuning walks through deriving the value from a trailing window.

Diagnostic Steps

Before building panels, confirm the store actually holds the metrics and routes you expect. A dashboard that renders a flat green line because the table is half empty is worse than no dashboard, because it manufactures false confidence.

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 behaviour, but verify it is intentional and not a sampling bug. A second sanity check confirms the rows carry a device dimension you can filter on, so the P75 is not silently blending cohorts:

psql "$PERF_DB_URL" -c "SELECT device_class, count(*) FROM web_vitals WHERE ts > now() - interval '1 day' GROUP BY device_class ORDER BY 2 DESC;"

If that column is missing or null for most rows, add it to the beacon before you trust the board; otherwise every threshold line is comparing against an unknown mixture.

Implementation

Import the dashboard as JSON so it is reproducible and lives in version control next to the app it measures. 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 device_class='mobile' 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 device_class='mobile' 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 device_class='mobile' 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{device_class="mobile"}[1h])))

Note the Prometheus form cannot reproduce the per-session maximum, because the histogram has already collapsed individual interactions into buckets at scrape time. If you rely on Prometheus, compute the per-session INP max in the beacon or a recording rule before it is bucketed; otherwise the histogram-quantile understates INP for the same reason the naive SQL does.

Why INP Needs a Two-Stage Aggregation

The INP query is the one to get right. INP is defined as the worst interaction latency a user experiences in a visit, so the field metric is the per-session maximum. The inner subquery reduces each session to its slowest interaction first, and the outer query takes the P75 across those session maxima. Computing the P75 directly over raw interaction events understates INP badly, because most interactions are fast taps and scrolls, and the metric is defined on the single worst one per visit.

INP two-stage aggregation Raw interactions per session are reduced to a per-session maximum, then a P75 is taken across the session maxima to produce the budget number. INP: per-session max, then P75 Raw interactions (ms) Session A: 40, 120, 80 Session B: 30, 60, 210 Session C: 50, 90, 140 Per-session max max = 120 max = 210 max = 140 P75 across sessions = 200 ms A naive P75 over all nine raw events returns roughly 90 ms — well under the true 200 ms, because fast interactions dominate the raw distribution
Reduce each session to its slowest interaction before taking the percentile, or INP reads far too low.

The same two-stage pattern reappears whenever you aggregate a per-visit worst case; Building P75/P99 Aggregation Pipelines generalises it beyond INP.

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. When the two drift apart, the board shows green while CI blocks the merge, and the team stops trusting both.

{
  "ci": {
    "assert": {
      "assertions": {
        "metric-lcp": ["error", { "maxNumericValue": 2500 }],
        "metric-inp": ["error", { "maxNumericValue": 200 }],
        "metric-cls": ["error", { "maxNumericValue": 0.1 }]
      }
    }
  }
}

The lab gate protects the mid-range-mobile-on-Fast-3G P75 you certify per release; the dashboard tracks the high-end-mobile 4G P75 your real audience experiences. They use the same metric names on purpose, but their numbers can legitimately differ if your field audience is faster than your lab profile. Once the board is live, wire the same threshold values into an alert so a breach pages someone instead of waiting to be noticed — that step is covered in Alerting on Performance Budget Regressions.

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, device_class, value, source) VALUES (now(), 'LCP', '/checkout', 'verify-1', 'mobile', 4200, 'test');"

On reload, the LCP panel's latest bucket P75 should rise above the dashed 2500 ms line and the area over 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 so the synthetic value does not pollute the real P75.

LCP P75 against budget line Seven hourly P75 bars; five sit under the 2500 millisecond budget line in green and the last two breach it in red. LCP P75 by hour vs budget 0 1000 2000 3000 4000 Budget 2500 ms P75 3100 2600 Seven hourly buckets; the last two breach the line and shade red
The threshold step turns a P75 series into a pass/fail signal readable at a glance — no axis reading required.

Handling Sparse Buckets and Route Mixing

Two failure modes make an otherwise correct board misleading. First, sparse buckets: on a low-traffic route, an hourly bucket may hold only a handful of samples, and a P75 over five points is statistically meaningless — one slow session swings it wildly. Widen the bucket to time_bucket('6 hours', ts) or require a minimum sample count with a HAVING count(*) > 100 clause so thin buckets simply do not render rather than plotting noise. Second, route mixing: a homepage and a checkout page have genuinely different LCP profiles, and a blended P75 hides a regression on the page that matters most. Add a Grafana template variable on route and default it to your revenue-critical path so the board opens on the page whose budget you most need to defend. Both fixes trade a little breadth for a number the team can actually trust, which is the whole point of a budget board.

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 reads far lower than the real INP. The inner subquery reduces each session to its slowest interaction before the percentile is taken.

What budget values should the threshold lines use?

The panels use 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.

Why does my P75 look flat and always green?

Usually the table is thinner than you assume, or a device filter is silently excluding the slow cohort. Run the diagnostic count queries first, confirm all three metrics have thousands of daily rows, and verify the panel is not filtering to a fast desktop segment. A P75 over a handful of samples per bucket is statistically meaningless and often stays deceptively calm.

Should the Grafana line and the CI gate use the same numbers?

They should use the same metric names and stay in deliberate lockstep, but the numbers can legitimately differ. The lab gate certifies a mid-range-mobile-on-Fast-3G profile per release; the dashboard tracks the high-end-mobile 4G P75 your live audience feels. Change both together and document why any gap exists so the board and CI never contradict each other silently.

How wide should each time bucket be?

Wide enough that every bucket holds enough samples for a stable P75 — as a rule of thumb, at least 100 samples. High-traffic routes tolerate one-hour buckets; low-traffic routes need six-hour or daily buckets, or a HAVING count(*) > 100 guard so thin buckets do not render noise. Sparse buckets are the most common cause of a jumpy, untrustworthy line.