The Problem
The audit tool has a four-step conversion funnel: someone scans a URL, reviews the results, enters their email for the full report, and books a call via Calendly. GA4 tracks each step as a separate event. The one number I couldn't get out of it was how many users who start a scan actually go on to book a call.
GA4 has no built-in way to correlate events across a multi-step flow. Each gtag('event', ...) call fires on its own, so without a shared identifier the events are just four disconnected data points. I needed a thread to tie them together, and I didn't want a backend to hold it.
The Session ID
The correlation key is a string generated once, at scan start, and attached to every subsequent funnel event:
const FUNNEL_SESSION_KEY = 'audit_funnel_session_id';
function generateFunnelSessionId(): string {
const id = `fs_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
sessionStorage.setItem(FUNNEL_SESSION_KEY, id);
return id;
}fs_ is a namespace prefix, Date.now() gives me temporal ordering, and the six random characters stop collisions when two tabs scan at the same time. The ID lives in sessionStorage because the funnel only means anything within a single browser session; if someone closes the tab and comes back tomorrow, that's a new funnel.
The Helper
A funnelParams() helper spreads the ID into any event that belongs to the funnel:
function getFunnelSessionId(): string | undefined {
if (typeof window === 'undefined') return undefined;
return sessionStorage.getItem(FUNNEL_SESSION_KEY) ?? undefined;
}
function funnelParams(): EventParams {
const id = getFunnelSessionId();
return id ? { funnel_session_id: id } : {};
}The SSR guard matters here, because Next.js pre-renders pages on the server where sessionStorage doesn't exist, and without the typeof window check the build crashes.
Wiring It Up
The first event generates the ID. Every subsequent event reads it:
auditScanStarted: (url: string) => {
const funnel_session_id = generateFunnelSessionId();
trackEvent('audit_scan_started', { url, funnel_session_id });
},
auditScanCompleted: (scanId: string, grade: string) =>
trackEvent('audit_scan_completed', {
scan_id: scanId,
grade,
...funnelParams(),
}),
auditEmailCaptured: (scanId: string) =>
trackEvent('audit_email_captured', {
scan_id: scanId,
...funnelParams(),
}),
auditCalendlyClicked: () =>
trackEvent('audit_calendly_clicked', {
...funnelParams(),
}),The spread pattern keeps each event definition flat, so adding a new funnel step is one line with ...funnelParams(). I deliberately tie ID generation to auditScanStarted rather than to page load, because the funnel begins when the user acts, not when they show up.
What GA4 Sees
Every event in the funnel carries the same funnel_session_id custom parameter, so in GA4 Explorations I build a funnel report filtered on it. That tells me, of the users who triggered audit_scan_started with a given session ID, how many reached audit_calendly_clicked under the same ID.
The same parameter works in BigQuery exports too; a single GROUP BY funnel_session_id joins all the events in one funnel instance.
The Takeaway
Cross-event correlation doesn't need a backend, a database, or a session management library. It's one generated string in sessionStorage, created the moment the user commits to the flow and threaded through every later event with a two-line helper. The one constraint that makes it work is to generate the ID on user action, not on page load, so the funnel starts when intent does.
