Overlaying Core Web Vitals on Conversion Funnels
A single conversion-rate number per performance band tells you slowness costs money; it does not tell you where in the purchase flow it costs the most. To find that, you overlay vitals onto every funnel step — landing, product, cart, checkout, purchase — so you can see that, say, Poor responsiveness on the cart step drops add-to-checkout progression by twelve points while the same band barely moves the landing-to-product step. This page is the build for that overlay, extending the Conversion Funnel Correlation work: a stable session id, dual emission onto RUM and analytics streams, a warehouse join, and a per-step banded conversion chart. It is the funnel-aware companion to the single-rate method in Mapping Core Web Vitals to Conversion Rates.
The two metrics that move funnel progression hardest are Largest Contentful Paint, which gates whether a step’s content is seen at all, and Interaction to Next Paint, which gates whether the click that advances the funnel feels instant. Both get banded per step. Banding uses the p75 aggregation and sampling thresholds, and the per-step interpretation feeds straight back into the broader User Impact Mapping practice.
Prerequisites
Before building the overlay, confirm the following are in place:
- A RUM beacon path already collecting LCP and INP from the field, landing in a warehouse table.
- A product-analytics stream that fires one event per funnel step the user reaches (
view_landing,view_product,view_cart,begin_checkout,purchase, or your equivalents). - Write access to a warehouse — BigQuery or ClickHouse — where both streams land, ideally partitioned by date.
- An agreed step ordering, so “progression” between adjacent steps is unambiguous.
- The p75 band definitions fixed up front, matching the current Google spec exactly:
| Metric | Good (p75) | Needs Improvement (p75) | Poor (p75) |
|---|---|---|---|
| LCP | ≤ 2.5 s | ≤ 4.0 s | > 4.0 s |
| INP | ≤ 200 ms | ≤ 500 ms | > 500 ms |
The overlay bands each session’s measured vitals against these thresholds, then aggregates conversion within the band — the p75 in the header names the percentile you report when you roll the bands up across many sessions.
How to build the overlay
1. Mint one stable session id
The whole overlay depends on a single id shared by the vitals beacon and every funnel event in the same visit. Generate it once, persist it for the session lifetime, and never regenerate it on soft navigations.
// session.js — one id for the whole visit, survives SPA route changes.
const SESSION_KEY = 'rum_sid';
const SESSION_TTL_MS = 30 * 60 * 1000; // 30-minute inactivity window
function readSession() {
try {
const raw = sessionStorage.getItem(SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (Date.now() - parsed.lastSeen > SESSION_TTL_MS) return null;
return parsed;
} catch {
return null;
}
}
export function getSessionId() {
let session = readSession();
if (!session) {
session = { id: crypto.randomUUID(), lastSeen: Date.now() };
}
session.lastSeen = Date.now();
try {
sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));
} catch {
// sessionStorage blocked (private mode / consent); id still valid in-memory.
}
return session.id;
}
Why: crypto.randomUUID() gives a collision-free id without a cookie, which keeps the join key out of GDPR cookie scope. sessionStorage is per-tab and clears when the tab closes, so the id maps cleanly to one visit and to one funnel attempt. The TTL refresh on each read rotates the id after inactivity so a returning user the next morning is a new session, not a stale join.
2. Emit the id on the RUM beacon
Attach the same id to every vitals sample so each beacon is joinable. The web-vitals library and PerformanceObserver deliver the metric; you decorate it with the session id and post it through the self-hosted beacon endpoint.
import { onLCP, onINP } from 'web-vitals';
import { getSessionId } from './session.js';
const VITALS_ENDPOINT = '/rum/vitals';
function sendVital(metric) {
const body = JSON.stringify({
session_id: getSessionId(),
metric: metric.name, // 'LCP' | 'INP'
value: metric.value, // ms
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
path: location.pathname,
ts: Date.now(),
});
// Beacon survives the unload that fires the final INP value.
navigator.sendBeacon(VITALS_ENDPOINT, body);
}
onLCP(sendVital);
onINP(sendVital);
Why: sendBeacon is non-blocking and fires reliably during visibilitychange/pagehide, which is exactly when INP and the final LCP are reported. The browser already classifies rating against the Good/NI/Poor thresholds, so you can either trust it or recompute the band in SQL — recomputing keeps the band definition in one auditable place.
3. Emit the same id on every funnel event
Pass the identical id into your analytics layer so each step event carries it.
import { getSessionId } from './session.js';
const FUNNEL_ENDPOINT = '/analytics/funnel';
export function trackFunnelStep(step) {
const payload = {
session_id: getSessionId(), // SAME id as the vitals beacon
step, // 'view_cart', 'begin_checkout', ...
path: location.pathname,
ts: Date.now(),
};
navigator.sendBeacon(FUNNEL_ENDPOINT, JSON.stringify(payload));
}
// At each step boundary:
trackFunnelStep('view_cart');
Why: calling getSessionId() in both emitters guarantees byte-identical join keys without threading the id through your component tree. Because the funnel event and the vitals beacon resolve the same sessionStorage entry, a warehouse join on session_id reconstructs the full visit even though the two streams arrive on different endpoints at different times.
4. Reduce vitals to one row per session and join in BigQuery
In the warehouse, collapse the many vitals beacons of a session down to that session’s representative value per metric, then left-join the funnel reach so non-converting sessions are not dropped.
-- BigQuery: band each session, then conversion rate per band per step.
WITH session_vitals AS (
SELECT
session_id,
MAX(IF(metric = 'LCP', value, NULL)) AS lcp_ms,
MAX(IF(metric = 'INP', value, NULL)) AS inp_ms
FROM `rum.vitals`
WHERE DATE(TIMESTAMP_MILLIS(ts)) BETWEEN '2026-06-01' AND '2026-06-18'
GROUP BY session_id
),
banded AS (
SELECT
session_id,
CASE
WHEN lcp_ms <= 2500 THEN 'Good'
WHEN lcp_ms <= 4000 THEN 'Needs Improvement'
ELSE 'Poor'
END AS lcp_band,
CASE
WHEN inp_ms <= 200 THEN 'Good'
WHEN inp_ms <= 500 THEN 'Needs Improvement'
ELSE 'Poor'
END AS inp_band
FROM session_vitals
WHERE lcp_ms IS NOT NULL
),
reached AS (
SELECT session_id, step, 1 AS reached
FROM `analytics.funnel`
GROUP BY session_id, step
)
SELECT
r.step,
b.lcp_band,
COUNT(DISTINCT r.session_id) AS sessions_at_step,
COUNT(DISTINCT nxt.session_id) AS advanced,
SAFE_DIVIDE(COUNT(DISTINCT nxt.session_id),
COUNT(DISTINCT r.session_id)) AS progression_rate
FROM reached r
JOIN banded b USING (session_id)
LEFT JOIN reached nxt
ON nxt.session_id = r.session_id
AND nxt.step = `analytics.next_step`(r.step) -- UDF mapping step -> next step
GROUP BY r.step, b.lcp_band
ORDER BY r.step, b.lcp_band;
Why: collapsing to one row per session with GROUP BY session_id prevents the fan-out that would inflate counts when a session has many beacons. COUNT(DISTINCT session_id) makes the progression rate the share of sessions at a step that reached the next step, which is the funnel definition stakeholders expect. The LEFT JOIN to the next step keeps drop-offs in the denominator instead of silently excluding them.
5. Run the same overlay in ClickHouse
If your pipeline self-hosts on ClickHouse, the shape is identical; the band logic uses multiIf and progression uses an array of step reach per session.
-- ClickHouse: conversion per LCP band per step.
WITH session_vitals AS (
SELECT
session_id,
maxIf(value, metric = 'LCP') AS lcp_ms,
maxIf(value, metric = 'INP') AS inp_ms
FROM rum.vitals
WHERE ts >= toUnixTimestamp(toDate('2026-06-01')) * 1000
AND ts < toUnixTimestamp(toDate('2026-06-19')) * 1000
GROUP BY session_id
),
banded AS (
SELECT
session_id,
multiIf(lcp_ms <= 2500, 'Good',
lcp_ms <= 4000, 'Needs Improvement',
'Poor') AS lcp_band
FROM session_vitals
WHERE lcp_ms > 0
),
reach AS (
SELECT
session_id,
groupUniqArray(step) AS steps
FROM analytics.funnel
GROUP BY session_id
)
SELECT
s.step AS step,
b.lcp_band AS lcp_band,
countDistinct(r.session_id) AS sessions_at_step,
countDistinctIf(r.session_id,
has(r.steps, dictGet('next_step', 'next', s.step))) AS advanced,
advanced / sessions_at_step AS progression_rate
FROM reach r
ARRAY JOIN r.steps AS step_at
JOIN banded b ON b.session_id = r.session_id
CROSS JOIN (SELECT arrayJoin(steps) AS step FROM reach) AS s
GROUP BY step, lcp_band
ORDER BY step, lcp_band;
Why: maxIf is ClickHouse’s columnar equivalent of the conditional aggregate, and pre-aggregating step reach into a groupUniqArray lets has() test next-step progression without a self-join across billions of rows. ClickHouse rewards keeping the per-session reduction cheap because the band CASE then runs over one row per session.
6. Chart conversion rate per band per step
Feed the query output into a grouped bar chart: x-axis is the funnel step, each step shows three bars (Good, NI, Poor), and the bar height is progression_rate. The diagnostic signal is the vertical gap between the Good and Poor bars at each step — a wide gap on the cart step and a narrow gap on the landing step means responsiveness on cart is where slowness is bleeding conversions, and that is where optimization buys the most revenue.
progression_rate
0.70 | G G
0.55 | N G N
0.40 | G N P N P P
+----------------------------------
view_product view_cart begin_checkout
(Good ≈ Poor) (wide gap) (medium gap)
Why: a single banded conversion number averages away the location of the damage. The per-step chart localizes it, so the LCP or INP fix lands on the step where the band gap — and therefore the lost revenue — is largest.
Verifying it works
Confirm the overlay before trusting any chart:
- In DevTools, open Application → Storage → Session Storage and confirm
rum_sidholds one value that does not change across in-app navigations. - In the Network panel, filter to both
/rum/vitalsand/analytics/funneland confirm the JSON payloads carry the samesession_id. - In the warehouse, run
SELECT COUNT(DISTINCT session_id) FROM rum.vitals AS v JOIN analytics.funnel AS f USING (session_id)— the join cardinality should be a large fraction of distinct vitals sessions. A near-zero overlap means the id is being regenerated between emitters. - Sanity-check that
sessions_at_stepfor the first step roughly equals total banded sessions; a large shortfall means funnel events are firing without the session id.
Edge cases & gotchas
sessionStorageblocked. In private mode or before consent, writes throw. Thetry/catchkeeps an in-memory id alive for the page, but a hard navigation loses it and splits the visit into two join keys. Where consent allows, fall back to a first-party value mirrored via the self-hosted beacon endpoint so multi-page funnels still stitch.- INP arrives late. INP is finalized at
visibilitychange/pagehide, often after the funnel step event has already been sent. The warehouse join tolerates this because it keys on session id, not arrival order — but never try to attach the vitals value to the funnel event client-side; you will capture a partial INP. - Sparse bands. Poor-band sessions at deep funnel steps can be thin. Suppress any band cell with fewer than a few hundred sessions and report the wide-interval rate rather than a noisy point estimate; the p75 sampling guidance covers the minimum volumes.
- Bots and prerenders. Synthetic agents fire funnel events without real vitals or vice versa, skewing bands. Filter on a non-zero LCP and a real interaction before banding.
- Step double-counting. A user revisiting the cart fires
view_carttwice; theGROUP BY session_id, step(BigQuery) andgroupUniqArray(ClickHouse) deduplicate reach so a single session counts once per step.
FAQ
Why band on the session’s measured value instead of a global p75?
Because the overlay needs per-session bands to compute a conversion rate within each band. The p75 is how you report each band’s value up the chain once sessions are grouped; the banding itself is per session against the fixed Good/NI/Poor thresholds.
Can I use a cookie instead of sessionStorage for the id?
You can, and a first-party cookie survives full page reloads better. But it pulls the join key into cookie-consent scope; sessionStorage keeps a no-cookie path open, which matters for the privacy-compliant builds.
Should I overlay LCP or INP first?
Start with whichever metric the step depends on: content-heavy steps (product, landing) are LCP-gated, while interaction-heavy steps (cart, checkout) are INP-gated. Run both bands and compare which produces the wider Good-vs-Poor conversion gap at each step.
Related
- Conversion Funnel Correlation — the parent practice of tying performance bands to multi-step purchase flows.
- Mapping Core Web Vitals to Conversion Rates — the single-rate session-id join this page extends per step.
- User Impact Mapping — the cross-pillar framing for translating vitals bands into user and revenue impact.