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.

Overlaying vitals bands onto each funnel step A stable session id is emitted on both vitals beacons and funnel-step analytics events, joined in the warehouse per step, then each step's sessions are banded Good, Needs Improvement, and Poor and a conversion rate is computed per band. Vitals beacons LCP, INP + session_id Funnel events step + session_id JOIN per step on session_id Good band CR per step Needs Improvement CR per step Poor band CR per step Output: conversion rate per band, per funnel step
One session id stitches vitals to every funnel step; bands derive from the same p75 thresholds documented in the RUM sampling and p75 aggregation material.

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_sid holds one value that does not change across in-app navigations.
  • In the Network panel, filter to both /rum/vitals and /analytics/funnel and confirm the JSON payloads carry the same session_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_step for the first step roughly equals total banded sessions; a large shortfall means funnel events are firing without the session id.

Edge cases & gotchas

  • sessionStorage blocked. In private mode or before consent, writes throw. The try/catch keeps 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_cart twice; the GROUP BY session_id, step (BigQuery) and groupUniqArray (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.

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.