Sample Ratio Mismatch in SPA experiments: why your GA4 split never matches the platform
You set a 50:50 split. The testing platform shows exactly that. GA4 shows 60:40. You run it another week, now it's 58:42. Never 50:50, never stable.
This is Sample Ratio Mismatch (SRM): the actual distribution of users in your analytics doesn't match the split you configured. On a single-page app, SRM between the testing platform and GA4 is common, often misdiagnosed, and has four distinct causes that are easy to conflate.
This article covers why each one happens, which ones our tracking fix directly addresses, and which ones require separate mitigation.
What SRM actually means here
There are two different counts in any A/B test setup:
- Platform count: users the testing platform bucketed into control or variation
- GA4 count: users whose conditions-met event arrived in GA4 and was processed into reports
SRM in the platform (bucketing imbalance) is a different problem. What we're dealing with is SRM between these two counts: the platform shows 50:50, GA4 shows something else. The mismatch isn't that users are being bucketed wrongly. It's that events from one bucket are failing to reach GA4, or arriving in a state GA4's reports don't count.
The instinct is to blame the experiment code. Usually the problem is downstream of it.
Four causes
1. Data layer not populated on first shell load
On a traditional site, the tag manager fires its page view call on every load. By the time a user can
interact with anything, the data layer is fully populated: customer_brand,
journey_type, page_type, store_id, whatever the config
enriches events with.
On a SPA, the initial load delivers a shell. Tealium loads, but utag.view() doesn't fire
until the first client-side route change. For users who land directly and trigger the experiment
before navigating anywhere, utag_data is sparse.
The sequence:
- User lands on the page → SPA shell loads,
utag_datais minimal - Experiment activates →
fireEvent()runs, event goes to GA4 with almost no context - User navigates to the next page →
utag.view()fires,utag_datafills up - Any event fired after this arrives in GA4 with full enrichment
The gap this creates isn't random. GA4 reports filter or segment on those tag manager-attached
parameters. An event without customer_brand or journey_type quietly drops
out of certain report views. The event was delivered (GA4 received it) but it's invisible to the
reports the client actually uses.
The same mechanism applies in GTM. Tags that fire on a virtualPageView
dataLayer push won't have those variables for events that fire before the first virtual page view.
If your GA4 tag depends on GTM variables like {{DL - pageType}}, those return undefined
on first shell load.
2. Beacon loss on navigation
When a user triggers an event and immediately navigates away, the network request carrying that event
gets cancelled. Standard fetch() and XMLHttpRequest calls are terminated
by the browser when the page unloads. The event fires in your code, disappears in transit.
The Beacon API exists specifically for this: navigator.sendBeacon() queues requests
internally and delivers them even after the page has gone. The browser manages delivery in the
background, independently of page lifecycle.
The key thing MDN notes: sendBeacon should be used with visibilitychange,
not with unload or beforeunload. Those legacy events are not reliably
fired on mobile, and calling sendBeacon inside them creates its own reliability issues.
Beacon loss creates a random, unstable split: 60:40 one day, 55:45 another, occasionally flipping direction. It doesn't correlate with which bucket is consistently under-reported the way data layer timing does.
3. Missing virtual pageview context
GA4 attaches page_location, page_title, and page_referrer to
every event based on its internal page state at the time the event fires. On a traditional site,
that state resets on every load. On a SPA, it only updates when something explicitly sends a
page_view event to GA4.
If virtual page views are not recorded for screen changes, Google Analytics treats the SPA as a single page, leading to skewed metrics. Without virtual page views, all engagement time is attributed to the initial page load, making it impossible to analyze time spent on individual screens.
If nobody sends a virtual page_view after a route change, your conditions-met event
fires with the previous page's URL attached. Depending on how the client's GA4 is configured (data
stream scoping, report filters, attribution rules), events attributed to the wrong URL can fall
outside the counts entirely.
GA4 resolves this with the History API, which allows it to fire the page_view event when
the URL changes even if the page does not fully reload. But this only works if Enhanced Measurement
has "Page changes based on browser history events" enabled, and if the SPA uses the History API
(pushState/replaceState) rather than hash-based routing. Hash-based SPAs require manual virtual page
view implementation.
This cause produces inconsistent attribution rather than pure event loss: events arrive but get counted against the wrong page.
4. Bot and crawler traffic
Bots get bucketed by the testing platform on page load. They don't execute the JavaScript that checks targeting conditions and fires the conditions-met event.
The result: the platform's total visitor count includes bots in both buckets. The GA4 conditions-met count excludes them. If bot traffic is distributed unevenly between buckets (which is common because randomisation is imperfect at small session counts), one bucket shows proportionally fewer conditions-met events than the platform count would predict.
SRM is a symptom, not the root problem. It might stem from faulty targeting, inconsistent bucketing, broken scripts, or filtering errors in your data pipeline.
The specific problem I found
On the client site in question, the symptom was an 8 to 12% variation under-report with no pattern. The first instinct was a GA4 timing race: variation doing more DOM work firing events at a different point in the gtag initialisation cycle. I built a defensive event queue with a warm-up event to confirm the pipeline before flushing:
window.gtag('config', measurementId);
window.gtag('event', 'gtag_ready_check', {
send_to: measurementId,
event_callback: () => {
ga4Ready = true;
flushQueue();
},
});
setTimeout(() => {
if (!ga4Ready) { ga4Ready = true; flushQueue(); }
}, 2000);
Solid defensive pattern. Didn't touch the actual problem.
What broke the case open was comparing two instances of the same event in GA4 DebugView. On initial
shell load: two custom parameters. After a route change, same event fired by the same code: twelve
parameters: customer_brand, tealium_environment,
tealium_profile, journey_type, and more.
Same event. Same code. Different data depending on when in the session it fired. That pointed at data layer population timing, not anything in the experiment code.
The second finding: my events weren't going through Tealium at all. They were calling
window.gtag directly, which meant no enrichment, no deduplication, and none of
Tealium's forwarding rules applied.
The third finding, found by intercepting utag.link in the console to capture what
Optimizely was sending on the same domain:
const original = window.utag.link;
window.utag.link = function(...args) {
console.log('UTAG LINK:', JSON.stringify(args[0], null, 2));
return original.apply(this, args);
};
The captured payload:
{
"eventCategory": "Experimentation",
"enhAction": "Experimentation",
"eventAction": "Screwfix - OptimizelyEdge",
"eventLabel": "Test ID: OptimizelyEdge Variation: on Label: Conditions Met",
"experimentId": "OptimizelyEdge",
"variationId": "on",
"non_interaction_hit": 1
}
camelCase keys. My snake_case payload was being silently dropped by Tealium's
mapping rules. Accepted by utag.link(), which takes any object, but matched no
configured forwarding rules.
The fix
Route experiment events through utag.link() with the payload shape matching what the
client's Tealium config expects. Mirror what's already working on the page.
window.utag.link({
eventCategory: 'Experimentation',
enhAction: 'Experimentation',
eventAction: `${_CLIENT} - ${_ID}`,
eventLabel: `Test ID: ${_ID} Variation: ${_VARIATION} Label: ${label}`,
experimentId: _ID,
variationId: _VARIATION,
non_interaction_hit: 1,
});
Events landed in GA4 with full enrichment. The 50:50 split appeared correctly in reports.
The full pattern with tag manager detection and fallback:
const sendEvent = (label) => {
// Tealium
if (window.utag) {
window.utag.link({
eventCategory: 'Experimentation',
eventAction: `${_CLIENT} - ${_ID}`,
eventLabel: `Test ID: ${_ID} Variation: ${_VARIATION} Label: ${label}`,
experimentId: _ID,
variationId: _VARIATION,
non_interaction_hit: 1,
});
return;
}
// GTM
if (window.dataLayer) {
window.dataLayer.push({
event: 'experimentation',
experiment_id: _ID,
experiment_variation: _VARIATION,
experiment_label: label,
});
return;
}
// Direct gtag fallback
if (window.gtag) {
window.gtag('event', 'experimentation', {
experiment_id: `${_ID}-${_VARIATION}`,
experiment_label: label,
transport_type: 'beacon',
send_to: _measurementId,
});
}
};
Does this fix reduce the SRM? And why?
This is the important question. The fix is specific (routing through utag.link with the
correct payload) but does it move the SRM number?
Cause 1 (data layer timing): yes, significantly
By routing through utag.link, events now go through Tealium's entire forwarding
pipeline. They pick up whatever is in utag_data at the time of call. More importantly,
the camelCase keys now match Tealium's mapping rules, so events are no longer silently dropped.
The improvement here isn't just about enrichment. It's about events becoming visible at
all. Events that previously arrived in GA4 via direct gtag without matching
customer_brand or journey_type were falling outside report filters. They
weren't counted. Now they are.
The data layer population timing gap still exists for first-load users: utag_data may
still be sparse before the first route change. But the events now reach GA4 through the correct
pipeline and are at minimum counted in the base report, even if enrichment is incomplete.
Cause 2 (beacon loss): yes, indirectly
Tealium Collect tag is configured to use navigator.sendBeacon() by default to send
tracking calls. By routing through utag.link, we inherit Tealium's transport. Events
are now sent via sendBeacon rather than XHR, which means they survive page navigation. Users who
bounce immediately after activation are no longer losing their conditions-met events in transit.
In the direct gtag fallback, transport_type: 'beacon' is added explicitly for the same
reason.
Cause 3 (missing virtual pageview context): partially
Our fix doesn't send the missing page_view events. That's a separate implementation
concern: either GA4 Enhanced Measurement handles it via the History API, or Tealium's
utag.view() is being called correctly on route change, or it needs to be added.
What the fix does help with: by passing event context explicitly in the utag.link
payload (eventAction containing the client and experiment ID), the event is at minimum
identifiable and filterable in GA4 reports even if page_location is wrong. Reports
filtering on experiment-specific parameters will still see it.
Cause 4 (bot traffic): no
Bot filtering is a platform concern. Optimizely and Convert have their own bot detection. Excluding bot traffic from GA4 comparisons is a reporting and segmentation task, not something experiment tracking code can fix.
What to do before the next experiment on a SPA
Run the intercept before writing any tracking code. Patch utag.link (or
dataLayer.push on GTM sites) in the console and capture a working event from whatever
is already firing correctly. The payload shape is the contract. Five minutes up front.
For GTM:
const orig = window.dataLayer.push.bind(window.dataLayer);
window.dataLayer.push = (...args) => {
console.log('DL:', JSON.stringify(args[0], null, 2));
return orig(...args);
};
For Adobe Launch:
const orig = window._satellite.track.bind(window._satellite);
window._satellite.track = (...args) => {
console.log('SAT:', JSON.stringify(args, null, 2));
return orig(...args);
};
Check whether virtual page views are being sent. Open GA4 DebugView and navigate the
SPA. You should see a page_view event each time the URL changes. If you don't, Enhanced
Measurement may not have history-based tracking enabled, or the SPA uses hash routing which GA4
doesn't detect automatically. In either case, the fix is a manual
gtag('event', 'page_view', { page_location: window.location.href }) call after each
route change, or a utag.view() call with the new page context.
Diagnose the SRM before assuming it's tracking. Compare the platform's total bucketed visitors against your conditions-met event count in GA4. If the platform shows 10,000 users and GA4 shows 8,500 conditions-met events, you have SRM between bucketing and event delivery. That's a tracking problem. If the platform shows 10,000 users and GA4 shows 10,000 conditions-met events but 6,000/4,000 between buckets, you have a different problem: the events are arriving but something about the distribution is wrong, which points at bot traffic, JS errors in targeting logic, or performance discrepancies between buckets.
Register GA4 custom dimensions before launch. Parameters like
experiment_id don't appear in reports until registered under
Admin → Custom definitions. GA4 does not backfill. Events fired before registration are
permanently invisible to standard reports even though the raw data exists. One email before launch:
Please register two custom dimensions in GA4 Admin → Custom definitions before we go live:
experiment_id, scope: Event
experiment_label, scope: Event
GA4 doesn't backfill, so any events fired before this is done will be unanalysable in standard reports.
The intercept approach and payload matching apply regardless of tag manager. The Tealium example here is specific to one client's config. Always confirm the expected payload shape on each new engagement before writing tracking code.
Questions or a different experience on Adobe Launch or GTM? Get in touch.