Conversion Funnel Correlation

Conversion funnel correlation is the practice of overlaying Real-User Monitoring performance data onto the discrete steps of a conversion funnel, so you can say not just “the page is slow” but “step three of checkout loses 4 percentage points of conversion whenever its 75th-percentile interaction latency crosses 500 ms.” It is the funnel-aware extension of the broader analytics layer described in Custom Metrics & Business Impact Tracking, and it answers the question every product-analytics lead eventually gets asked: which step is the slow one costing us money, and how much? The method is mechanical once the plumbing is right — emit a funnel-step event keyed by the same session id your RUM beacons already carry, join the two streams in the warehouse, bucket each step’s sessions into Good / Needs Improvement / Poor bands for the metric that governs that step, and compute conversion rate per band per step. This page covers the metric-to-step mapping, the client-side id stitching and warehouse join, a debugging workflow, field segmentation, and the failure modes — id mismatch, survivorship bias, and sampling skew — that quietly invalidate the analysis.

Per-step conversion by performance band Funnel-step events and RUM beacons share a session id, are joined per step in the warehouse, bucketed into Good, NI, and Poor bands, and the conversion drop is attributed to the slowest step. Funnel-step events view, add_to_cart, pay RUM beacons LCP, INP, CLS + session_id JOIN per step session_id + step key p75 band / step Good / NI / Poor Step 1 view 92% advance Step 2 cart 61% advance (slow) Step 3 pay 88% advance Attribution: Step 2 drop-off concentrates in the Poor INP band — fix the slow step first Control confounders: device, network, geo, traffic source, price/intent skew Failure modes: id mismatch, survivorship bias, sampling skew
Each funnel step is joined to its own performance band; the drop concentrated in one band on one step is the lever. For the lift-curve walkthrough see Overlaying Core Web Vitals on Conversion Funnels.

Why funnel-level correlation beats page-level correlation

A page-level correlation between a vital and conversion — the method covered in User Impact Mapping — answers whether performance matters at all. Funnel-level correlation answers where it matters, which is the question that gets a fix scheduled. The two are complementary: page-level mapping is the proof of concept that performance moves revenue; funnel correlation is the targeting system that aims the engineering budget at one step instead of the whole site.

The reason this distinction is load-bearing is that a funnel is a product of conditional probabilities. Overall conversion is the multiplication of each step’s advance rate, so a 4-point drop on a single mid-funnel step compounds through every downstream step. If you only look at the aggregate, a localized performance problem on step two looks like a diffuse, site-wide softness and gets diagnosed as “the checkout is generally slow” — which leads to scattered, low-yield optimization. Per-step attribution turns that into “step two’s INP p75 is in the Poor band on mobile, and that band converts 14 points worse,” which is a ticket with an owner.

Different steps are governed by different vitals, and that is the second reason funnel granularity matters. The landing step is dominated by load — LCP and FCP/TTFB. Interactive steps such as filtering, adding to cart, and applying a coupon are dominated by INP. Steps with injected content or expanding summaries are exposed to CLS-driven misclicks. Correlating the wrong metric to a step produces a null result and wrongly exonerates performance.

Mapping metrics to funnel steps and KPIs

Before any SQL, write down which metric governs which step and which KPI the step controls. This table is the contract the rest of the analysis is built against; bucket every step’s sessions into the band for its governing metric, using the current Google thresholds, because the conversion relationship has a cliff at each band boundary rather than a smooth slope.

Funnel step Governing metric Good (p75) NI (p75) Poor (p75) Step KPI Engineering action when Poor
Landing / category view LCP ≤ 2.5 s ≤ 4.0 s > 4.0 s View → engage rate Prioritize the LCP element; preload the hero
Search / filter / sort INP ≤ 200 ms ≤ 500 ms > 500 ms Engage → product-view Break up long tasks; yield the main thread
Add to cart INP ≤ 200 ms ≤ 500 ms > 500 ms Product → cart rate Debounce handlers; defer non-critical work
Cart / summary render CLS ≤ 0.1 ≤ 0.25 > 0.25 Cart → checkout rate Reserve space for totals, promos, banners
Checkout entry FCP / TTFB ≤ 1.8 s / ≤ 0.8 s Checkout-start rate Edge-cache the shell; cut backend latency
Payment submit INP ≤ 200 ms ≤ 500 ms > 500 ms Pay-submit success Avoid synchronous validation on click

The FCP and TTFB rows carry only a Good threshold in the current spec; treat them as proxies and control variables rather than banded levers. The discipline is one metric per step — the metric the user is actually waiting on at that moment — not all four metrics smeared across every step.

Implementation: client session-id stitching and the warehouse join

The whole method rests on one shared key written into both streams: a stable session_id carried by every RUM beacon and every funnel-step event, plus a step identifier so the warehouse can join per step. Generate the id once per session, persist it in sessionStorage, and attach it to both. The vitals capture below uses the web-vitals library and PerformanceObserver and finalizes on the page-lifecycle events, because INP in particular is only final at page hide.

import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals/attribution';

// One stable id for the whole session, shared by beacons and funnel events.
function getSessionId() {
  let id = sessionStorage.getItem('rum_sid');
  if (!id) {
    id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
    sessionStorage.setItem('rum_sid', id);
  }
  return id;
}
const SESSION_ID = getSessionId();

function cohort() {
  const c = navigator.connection || {};
  return {
    device: matchMedia('(pointer: coarse)').matches ? 'mobile' : 'desktop',
    network: c.effectiveType || 'unknown'
    // geo is resolved server-side from the request IP, never client-side.
  };
}

// --- Funnel-step events: same session_id, plus a step key and ordinal. ---
const STEP_ORDINAL = { view: 1, filter: 2, add_to_cart: 3, cart: 4, checkout: 5, pay: 6 };

export function trackStep(step, extra = {}) {
  const payload = {
    type: 'funnel_step',
    session_id: SESSION_ID,
    step,
    step_no: STEP_ORDINAL[step] ?? 0,
    route: location.pathname,
    ts: Date.now(),
    ...cohort(),
    ...extra
  };
  // Beacon transport so the event survives a navigation away from the step.
  navigator.sendBeacon('/rum/funnel', JSON.stringify(payload));
}

// --- Vitals beacons: same session_id, tagged with the step in view. ---
let currentStep = 'view';
export function setStep(step) { currentStep = step; }

const buffer = [];
function record(metric) {
  buffer.push({
    type: 'vital',
    session_id: SESSION_ID,
    step: currentStep,
    name: metric.name,
    value: Math.round(metric.value),
    rating: metric.rating,          // 'good' | 'needs-improvement' | 'poor'
    route: location.pathname,
    nav_id: metric.navigationId,
    ...cohort()
  });
}
onLCP(record); onINP(record); onCLS(record); onFCP(record); onTTFB(record);

function flush() {
  if (!buffer.length) return;
  navigator.sendBeacon('/rum/collect', JSON.stringify({ batch: buffer.splice(0) }));
}
addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});
addEventListener('pagehide', flush);

Call setStep('add_to_cart') and trackStep('add_to_cart') together as the user advances, so each vital beacon is tagged with the step that was on screen when it was measured. Two transports both use sendBeacon because funnel steps and the final vitals flush both happen as the user is navigating away, where fetch() is unreliable. The beacon volume is governed by your sampling and p75 aggregation strategy; for funnel correlation you must sample at the session level — keep or drop a whole session’s beacons and funnel events together — or you will join a kept funnel event to a dropped vital and silently bias the band counts.

Once both streams land in the warehouse, the join is per session and per step. The query below computes conversion-to-next-step per metric band for one funnel step, which is the atomic unit of the entire analysis.

-- Conversion from step 3 (add_to_cart) to step 4 (cart), by INP band, last 28 days.
WITH step_vitals AS (
  SELECT
    v.session_id,
    APPROX_QUANTILES(v.value, 100)[OFFSET(75)] AS inp_p75_ms,
    ANY_VALUE(v.device)  AS device,
    ANY_VALUE(v.network) AS network
  FROM `project.rum.beacons` v
  WHERE v.name = 'INP'
    AND v.step = 'add_to_cart'
    AND v.ts > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 28 DAY)
  GROUP BY v.session_id
),
banded AS (
  SELECT
    sv.session_id, sv.device, sv.network, sv.inp_p75_ms,
    CASE
      WHEN sv.inp_p75_ms <= 200 THEN 'good'
      WHEN sv.inp_p75_ms <= 500 THEN 'ni'
      ELSE 'poor'
    END AS inp_band
  FROM step_vitals sv
),
reached_step AS (
  SELECT DISTINCT session_id, step FROM `project.rum.funnel`
)
SELECT
  b.inp_band,
  b.device,
  COUNT(*) AS at_add_to_cart,
  COUNTIF(c.session_id IS NOT NULL) AS advanced_to_cart,
  SAFE_DIVIDE(COUNTIF(c.session_id IS NOT NULL), COUNT(*)) AS advance_rate
FROM banded b
-- only sessions that actually reached the source step belong in the denominator
JOIN reached_step src ON src.session_id = b.session_id AND src.step = 'add_to_cart'
LEFT JOIN reached_step c ON c.session_id = b.session_id AND c.step = 'cart'
GROUP BY b.inp_band, b.device
ORDER BY b.device, b.inp_band;

The non-obvious correctness detail is the JOIN reached_step src: the denominator for a step’s advance rate must be sessions that reached that step, not all sessions. Computing advance rate over all sessions mixes in users who bounced on the landing page and produces a meaningless number. Run this query once per funnel transition, stack the results, and you have the per-step, per-band conversion table that drives attribution.

Attributing drop-off to the slow step

With one row per (step, band, cohort), attribution is reading the table for the step where the Poor band’s advance rate diverges most sharply from the Good band’s. Quantify it as the band gap per step — Good advance rate minus Poor advance rate — and rank steps by that gap weighted by how many sessions sit in the Poor band. A large gap on a step nobody reaches in the Poor band is not worth fixing; a moderate gap on a step where 30% of mobile sessions are Poor is the top lever.

-- Rank funnel transitions by performance-attributable drop-off.
SELECT
  step,
  device,
  MAX(IF(band = 'good', advance_rate, NULL)) AS good_rate,
  MAX(IF(band = 'poor', advance_rate, NULL)) AS poor_rate,
  MAX(IF(band = 'good', advance_rate, NULL))
    - MAX(IF(band = 'poor', advance_rate, NULL)) AS band_gap,
  SUM(IF(band = 'poor', sessions, 0)) AS poor_sessions,
  (MAX(IF(band = 'good', advance_rate, NULL))
    - MAX(IF(band = 'poor', advance_rate, NULL)))
    * SUM(IF(band = 'poor', sessions, 0)) AS attributable_loss
FROM `project.rum.step_band_rates`
GROUP BY step, device
ORDER BY attributable_loss DESC;

The attributable_loss column estimates the sessions lost to performance at each step: the band gap (the conversion penalty of being slow) times the number of sessions actually paying that penalty. Sort by it and the slow step that costs the most money is the top row. This is the number you carry into the ROI model in User Impact Mapping to attach a dollar figure, and the lift-curve detail of how the gap responds to a fix is worked through in Overlaying Core Web Vitals on Conversion Funnels.

Debugging workflow

When a funnel correlation looks wrong — a step you expected to be slow shows no band gap, or the numbers swing week to week — work it as a numbered sequence rather than distrusting the metrics outright.

  1. Verify the join rate per step. Count funnel events that have at least one matching vital beacon for the same session and step. A join rate that collapses on one step means the vital was never captured there (common when the step is a fast SPA route transition that the observer missed), not that performance is fine.
  2. Trace the waterfall for the suspect step. Pull a slow exemplar session and inspect the resource and interaction waterfall for that route. Confirm the governing metric you chose is actually the user’s wait — a step you mapped to INP may really be blocked on a late XHR, making it an LCP/TTFB problem.
  3. Correlate overlapping steps. If two adjacent steps share a route in an SPA, vitals can be misattributed across the boundary. Check that setStep is called before the interaction, not after, so the metric lands on the correct step.
  4. Validate the band cliff in the lab. Reproduce the Poor-band condition under throttling in DevTools and confirm the step’s interaction genuinely crosses the threshold; if it does not, your field band is being inflated by a tail of outlier sessions.
  5. Deploy the fix behind a flag. Ship to a randomized fraction so you measure the advance-rate delta on the affected step against a concurrent control, not against last week.
  6. Monitor the per-step delta. Watch the band gap on the targeted step contract after rollout. The gap, not the overall conversion rate, is the signal that the performance fix worked — overall conversion moves slowly and is buried in noise.

Field-data segmentation

Funnel correlation is only trustworthy within a cohort, because slow sessions are not a random sample of your traffic — they skew toward cheaper devices, worse networks, and geographies that convert differently for reasons unrelated to speed. Segment every step’s band table by the dimensions below and never pool the correlation across them.

Segment Why it matters at the funnel level Divergence to watch
Device class Mobile dominates the Poor INP band on interactive steps A band gap that exists only on mobile points to main-thread cost
Network type 3G/slow-4G inflates LCP and TTFB on entry steps Drop-off on the landing step concentrated in slow networks
Geography Edge distance and backend region change TTFB by step One region’s checkout-entry band gap implies a routing/cache miss
Traffic source Paid and high-intent traffic converts at a different baseline Pooling masks performance effects under intent differences
Cart value / intent High-value carts tolerate slower steps before abandoning Confounds the band gap if price is correlated with device

The cart-value row is the subtle one specific to funnels: purchase intent rises through the funnel, so a Poor-band session deep in checkout has already self-selected for high intent and may abandon less than a Poor-band session on the landing page. Always compare bands within the same step and the same intent segment, never across steps.

Failure modes and gotchas

  • Session-id mismatch. The single most destructive failure: if the funnel SDK and the RUM SDK generate ids independently, or one regenerates the id on an SPA route change, the per-step join silently drops to near zero and every band rate is computed on whatever sliver happened to match. Write the id once, share it across both SDKs, and alert when the per-step join rate falls below a threshold.
  • Survivorship bias. Sessions that abandon a step fastest often abandon because it was slow, and they frequently leave before the final vitals flush — so the Poor band of the very step you are studying is under-counted for its worst outcomes, which understates the drop-off. Emit an early, minimal beacon on first interaction with each step, separate from the lifecycle flush, so abandoned sessions still contribute a row.
  • Sampling skew. Beacon-level sampling can keep a funnel event while dropping its paired vital (or vice versa), corrupting the band assignment. Sample whole sessions, keep the rate stable across the comparison window, and never change the rate mid-analysis — a rate change is indistinguishable from a real shift in the band mix.
  • Wrong governing metric. Mapping a step to a metric the user is not waiting on (INP on a static render step, LCP on a click handler) yields a null band gap and falsely clears performance. Re-derive the governing metric from the waterfall, not from assumptions.
  • Step boundary leakage in SPAs. When two steps live on one route, a vital can be attributed to the wrong step if setStep lags the interaction. Set the step before the user acts.
  • Reverse causation. Engaged users interact more and therefore accumulate more INP events and more layout shift; heavy interaction on a step can be a symptom of engagement, not a cause of abandonment. Control with an engagement covariate before claiming the slow step caused the drop.

CI and reporting cadence

Gate the governing metric per step in CI, and re-run the funnel join on a fixed cadence so the attribution table never goes stale. The most important guard is the per-step join rate: a tracking change that breaks the shared id invalidates every band rate without throwing an error.

# Per-step performance budgets plus the funnel-correlation refresh cadence.
budgets_p75_by_step:
  landing:     { lcp_ms: 2500, ttfb_ms: 800 }
  filter:      { inp_ms: 200 }
  add_to_cart: { inp_ms: 200 }
  cart:        { cls: 0.1 }
  checkout:    { fcp_ms: 1800 }
  pay:         { inp_ms: 200 }

gates:
  fail_build_on_breach: true
  tolerance_pct: 5

funnel_correlation:
  cadence: weekly
  segment_by: [device, network, geo, traffic_source]
  require_step_join_rate_above: 0.85   # alert if any step's beacon↔funnel join degrades
  rank_by: attributable_loss           # surface the costliest slow step first
  ab_required_for_roi_claim: true      # no dollar figure without a randomized test

FAQ

How is funnel correlation different from page-level impact mapping?

Page-level mapping proves performance moves revenue across the whole site; funnel correlation tells you which step the loss happens on. It joins funnel-step events to RUM beacons by session id and computes conversion-to-next-step per p75 band per step, so a localized slow step is isolated instead of being averaged into a diffuse site-wide softness.

Which vital should I correlate to each funnel step?

Map each step to the metric the user is actually waiting on at that moment: LCP and FCP/TTFB for landing and render steps, INP for interactive steps like filtering, add-to-cart, and payment submit, and CLS for steps with injected or expanding content. Correlating the wrong metric to a step produces a null result and wrongly exonerates performance.

How do I keep the beacon and funnel event session ids in sync?

Generate one id per session, persist it in sessionStorage, and have both the RUM SDK and the funnel SDK read that same key — never let either generate its own. Verify the id survives SPA route changes, and alert when any step’s beacon-to-funnel join rate drops, because an id mismatch silently collapses the join without erroring.

Why must I sample whole sessions instead of individual beacons?

Beacon-level sampling can keep a funnel event while dropping its paired vital, which corrupts that session’s band assignment and biases the per-step rates. Sampling whole sessions keeps each session’s vitals and funnel events together, and holding the rate stable across the comparison window keeps a sampling change from masquerading as a real shift.

How do I attribute drop-off to a specific slow step?

Compute the band gap per step — Good advance rate minus Poor advance rate — then multiply by the number of sessions sitting in the Poor band to get attributable loss, and rank steps by it. The top row is the slow step costing the most conversions, which becomes the input to the ROI model.