Mapping Core Web Vitals to Conversion Rates
You have a RUM stream and a conversion stream, and a stakeholder asking “how much money is slow LCP costing us?” Answering that demands more than a scatter plot: you need to stitch every vitals beacon to the exact session that did or did not convert, bucket those sessions into performance bands, and compute the conversion rate per band so the lift is defensible. This page is the concrete method behind that — a deterministic session-id join, p75-anchored bucketing, and a runnable SQL/JS recipe — extending the broader User Impact Mapping cluster within Core Web Vitals & Performance Metrics Fundamentals. For a complementary view that follows the same data through a multi-step purchase flow, the Conversion Funnel Correlation work overlays bands per funnel step.
The two metrics that move revenue most directly are Largest Contentful Paint — how fast the buyer sees the offer — and Interaction to Next Paint — how responsive the page feels once they start clicking. We band on both.
Prerequisites
- A RUM beacon stream where each vitals row carries a stable
session_id, ametric_name, ametric_value, and an event timestamp — captured with the web-vitals library and PerformanceObserver. - A conversion stream (orders, signups, leads) that can emit the same
session_idyou set in the browser. This is the single hardest requirement; most failed analyses fail here. - A columnar warehouse — ClickHouse or BigQuery — holding both streams. The SQL below targets both dialects with notes where they diverge.
- An agreed p75 aggregation and sampling policy so band counts are not skewed by an unsampled bot fleet.
- Conversion-event delivery decoupled from the vitals beacon. Never gate the purchase confirmation on
sendBeaconsucceeding.
How to map Core Web Vitals to conversion rates
1. Mint one deterministic session id and stitch it to every event
Both streams must agree on the key. Persist a UUID in sessionStorage (survives SPA route changes, not new tabs), and expose it to the conversion layer so the server can stamp the same id on the order record.
// session-id.js — single source of truth for the join key
export function getSessionId() {
let id = sessionStorage.getItem('rum_session_id');
if (!id) {
id = crypto.randomUUID();
sessionStorage.setItem('rum_session_id', id);
}
return id;
}
// Make it readable by the conversion/checkout layer (e.g. a hidden field
// posted with the order, or a header on the purchase XHR).
export function attachSessionIdToOrderForm(formEl) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'rum_session_id';
input.value = getSessionId();
formEl.appendChild(input);
}
Why: the join is only as good as the key. A re-minted id on every page view fragments a session into orphan rows that can never be matched to the conversion that happened three navigations later. Persisting in sessionStorage keeps one id across the whole funnel; threading it into the order POST is what lets the warehouse JOIN.
2. Beacon vitals with the session id attached
Capture LCP and INP, tag each with the session id, and flush on page hide via the self-hosted beacon collection endpoint.
import { onLCP, onINP } from 'web-vitals/attribution';
import { getSessionId } from './session-id.js';
const SESSION_ID = getSessionId();
const queue = [];
function record(metric) {
queue.push({
session_id: SESSION_ID,
metric_name: metric.name, // 'LCP' | 'INP'
metric_value: Math.round(metric.value),
navigation_id: metric.navigationType,
ts: Date.now(),
});
}
function flush() {
if (!queue.length) return;
const body = JSON.stringify(queue.splice(0));
navigator.sendBeacon('/api/v1/rum/ingest', body) ||
fetch('/api/v1/rum/ingest', { method: 'POST', body, keepalive: true });
}
onLCP(record);
onINP(record);
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
addEventListener('pagehide', flush);
Why: LCP and INP both finalize late — LCP when loading stops, INP after the worst interaction. Flushing on visibilitychange/pagehide (not onLoad) captures the final value, which is the one Google reports at p75 and the only one worth banding on. The keepalive fetch is the fallback for browsers that drop the sendBeacon.
3. Pivot beacons to one row per session
Vitals arrive as long-form rows. Collapse them so each session has one lcp_ms and one inp_ms.
-- ClickHouse / BigQuery: one row per session with its final vitals
WITH session_vitals AS (
SELECT
session_id,
maxIf(metric_value, metric_name = 'LCP') AS lcp_ms,
maxIf(metric_value, metric_name = 'INP') AS inp_ms
FROM rum_events
WHERE ts >= now() - INTERVAL 30 DAY
GROUP BY session_id
)
SELECT * FROM session_vitals;
On BigQuery, swap maxIf(x, cond) for MAX(IF(cond, x, NULL)) and now() - INTERVAL 30 DAY for TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY).
Why: banding needs a single scalar per metric per session. Taking the max (the worst final value) keeps you aligned with how the spec aggregates: the user experienced the slowest interaction, not the average one.
4. Join conversions and assign each session to a p75 band
Left-join the conversion stream so non-converters survive (a 0), then label each session Good / Needs Improvement / Poor using the exact spec thresholds.
WITH session_vitals AS (
SELECT
session_id,
maxIf(metric_value, metric_name = 'LCP') AS lcp_ms,
maxIf(metric_value, metric_name = 'INP') AS inp_ms
FROM rum_events
WHERE ts >= now() - INTERVAL 30 DAY
GROUP BY session_id
),
joined AS (
SELECT
v.session_id,
v.lcp_ms,
v.inp_ms,
-- one converting event in the session marks the whole session converted
if(c.session_id != '', 1, 0) AS converted
FROM session_vitals v
LEFT JOIN (SELECT DISTINCT session_id FROM conversions
WHERE event_name = 'purchase_complete') c
ON v.session_id = c.session_id
),
banded AS (
SELECT
*,
multiIf(lcp_ms <= 2500, 'Good',
lcp_ms <= 4000, 'Needs Improvement',
'Poor') AS lcp_band,
multiIf(inp_ms <= 200, 'Good',
inp_ms <= 500, 'Needs Improvement',
'Poor') AS inp_band
FROM joined
)
SELECT
lcp_band,
count() AS sessions,
sum(converted) AS conversions,
round(sum(converted) / count(), 4) AS conversion_rate
FROM banded
WHERE lcp_ms IS NOT NULL
GROUP BY lcp_band
ORDER BY conversion_rate DESC;
On BigQuery: replace if(...)/multiIf(...) with IF(...) / nested CASE WHEN, count() with COUNT(*), and c.session_id != '' with c.session_id IS NOT NULL.
Why: the bands come straight from the Google field thresholds, so a “Good” session here is exactly a “Good” session in any other vitals tool — the analysis is portable and audit-proof. The LEFT JOIN plus the 0/1 flag is what turns “did anyone convert” into a rate per band rather than a count of orders.
5. Compute the lift between bands
The headline number is the relative conversion difference between the best and worst band, computed per metric so you can rank where optimization money goes.
WITH band_rates AS (
-- feed the banded CTE from step 4 here
SELECT lcp_band AS band,
round(sum(converted) / count(), 4) AS cr
FROM banded
WHERE lcp_ms IS NOT NULL
GROUP BY lcp_band
)
SELECT
maxIf(cr, band = 'Good') AS good_cr,
maxIf(cr, band = 'Poor') AS poor_cr,
round((maxIf(cr, band = 'Good') - maxIf(cr, band = 'Poor'))
/ maxIf(cr, band = 'Poor'), 3) AS relative_lift
FROM band_rates;
Why: absolute rates differ across sites, but relative lift — “Good-LCP sessions convert 19% more often than Poor-LCP sessions” — is the sentence executives act on and the input to the ROI model below.
Worked example and ROI estimate
A 30-day pull on a checkout flow produces this LCP banding:
| LCP band | Threshold | Sessions | Conversions | Conversion rate |
|---|---|---|---|---|
| Good | ≤ 2.5 s | 120,000 | 3,720 | 3.10% |
| Needs Improvement | ≤ 4.0 s | 60,000 | 1,620 | 2.70% |
| Poor | > 4.0 s | 40,000 | 880 | 2.20% |
Relative lift Good vs Poor = (0.0310 − 0.0220) / 0.0220 = +40.9%.
For an ROI estimate, model what moving the Poor cohort into the Good band would earn. If those 40,000 Poor sessions converted at the Good rate (3.10%) instead of 2.20%, that is 40,000 × (0.0310 − 0.0220) = 360 additional conversions per 30 days. At an average order value of $85:
incremental_conversions = poor_sessions * (good_cr - poor_cr)
= 40,000 * (0.0310 - 0.0220) = 360
incremental_revenue = 360 * $85 = $30,600 / 30 days
annualized ≈ $30,600 * 12.17 ≈ $372,000 / year
Treat this as an upper bound: it assumes the LCP gap is causal, not merely correlated with a faster device or richer-intent cohort. Hold the device-class and traffic-source segments fixed (band within each segment) before quoting the figure, and confirm the gap survives that control.
Verifying it works
- Join coverage: run
SELECT countIf(c.session_id != '') / count() FROM session_vitals v LEFT JOIN conversions c ON .... If under ~95% of conversions match a vitals row, the session id is not threading through to the order layer — fix step 1 before trusting any rate. - Band sanity: the Good band should hold the largest session count on a healthy site, and conversion rate should decline monotonically Good → NI → Poor. A non-monotonic curve usually means an unbanded confounder (bot traffic, a single broken geo).
- Live console check: in DevTools, run
sessionStorage.getItem('rum_session_id')on the product page, complete a test purchase, and confirm the order record in the warehouse carries the identical id. - RUM dashboard signal: plot conversion rate by band over time. A widening Good-vs-Poor gap after a regression deploy is the canary that a performance change is costing conversions.
Edge cases and gotchas
sessionStoragedoes not cross tabs or subdomains. A buyer who opens checkout in a new tab gets a fresh id and looks like an orphan session. For cross-subdomain checkouts, pass the id via a first-party cookie or a URL parameter on the handoff.- Non-converters have no conversion row. An
INNER JOINsilently drops every session that did not buy and inflates every rate toward 100%. AlwaysLEFT JOINfrom the vitals side. - Late INP underreporting. If you flush only on
load, INP is captured before the user’s slowest interaction and the Poor band empties out artificially. Flush onpagehide/visibilitychange(step 2). - Survivorship bias from bounced sessions. Sessions that never reached the conversion page should be excluded from a checkout analysis or they drag the rate down uniformly across bands. Scope the cohort to sessions that hit the funnel entry page.
- p75 drift. Bands use fixed spec thresholds, but your traffic mix shifts weekly; re-pull on a rolling 7-day window so a holiday device-mix swing is not misread as a performance regression.
- Sampling skew. If beacons are sampled but conversions are not, rates are distorted. Apply the same sampling rate to both, or weight by the documented sampling strategy.
FAQ
Should I band on LCP or INP first?
Band on both separately and compare the relative lift. Whichever metric shows the larger Good-vs-Poor conversion gap is where optimization spend returns the most. On content-and-offer pages LCP usually dominates; on interaction-heavy flows like multi-step checkout, INP often wins.
Why bucket into bands instead of running a regression?
Bands map directly to the thresholds Google ranks on and to engineering work items (“move Poor into NI”). They are explainable to non-statisticians and robust to the non-linear, threshold-shaped relationship between vitals and conversion. A regression is a useful second pass, not the headline.
How do I prove the lift is causal, not correlated?
Band within fixed segments — device class, network, traffic source — so a faster-device cohort cannot masquerade as a performance effect. The defensible proof is an A/B test where the only difference is a performance patch, then re-run the banding on each arm.
Related
- User Impact Mapping — the parent cluster on turning field metrics into user and business outcomes.
- Overlaying Core Web Vitals on Conversion Funnels — the same join applied step-by-step across a multi-stage funnel.
- Custom Metrics & Business Impact Tracking — the wider practice of binding telemetry to revenue.