dataLayer timing & race conditions
dataLayer timing and race conditions is the firing of a tag before the dataLayer.push that carries its values has run, so the tag reads undefined and ships with the items and value parameters dropped from the hit. The data is on the page and reaches the model a beat later, so the parameters drop on a fraction of hits and the symptom flickers.
Symptom and cause
- Symptom
- Parameters there one day, missing the next: items arrays empty, values intermittently zero. The same tag passes in testing and fails for real users.
- Cause
- Tags fire before the dataLayer is populated. The tag races the push, or fires on page view while the ecommerce object arrives a beat later.
- Where it's caught
- GTM Preview: variables resolving undefined on first fire; GA4 events arriving with missing parameters on a fraction of hits only.
How the tag outruns the data
A GTM tag reads its Data Layer Variables at the instant its trigger fires. If the dataLayer.push that populates those values has not yet run, the variables resolve undefined. GA4 then drops the undefined items and value parameters, so they are absent from the hit. Google's own documentation is explicit. A pushed value is queued and processed after all other pending messages, so updated values are not guaranteed to be available for the next event. Tag Manager evaluates messages in order, and a tag can only see what was in the model at or before its trigger. The common shape is a GA4 event tag bound to a page-load-family trigger (Consent Initialization, Initialization, Page View, DOM Ready, Window Loaded or All Pages) while the ecommerce object is pushed by the page template a moment later, so the tag out-races the push. Placement is the tell. If the push carrying the data sits below the GTM container snippet in the source, it is not in the model when Page View fires, though a synchronous inline push placed there will be in the model from DOM Ready onward. Note that Consent Initialization and Initialization fire before Page View, so Page View is not the earliest moment. The fault is intermittent by nature. Which async path wins on a given load decides the outcome, and a push may wait on framework hydration, an API or cart call, or a deferred script, so fast cached loads can flip the order relative to slow ones. The correct fix re-sequences the firing. A Custom Event trigger keyed to the event name of the push itself (purchase, view_item, the case-sensitive value of the event key) fires only when that key appears in a dataLayer.push, so the data and the trigger are guaranteed to coexist, and the push must carry the event key alongside the values it consumes. Only an event-key push can fire a tag, and an event key gives tags access to the state of the dataLayer at the time of that push, so a data-only push without an event key can be overwritten before any tag reads it. A second, related behaviour sits underneath this. GTM's data model is merged, not reset, between pushes (Data Layer Version 2 recursive merge), so a key from an earlier push can go stale and bleed into a later event. That produces wrong-but-present values, distinct from the fire-before-push race, which produces absent ones. The single-page-app version is the same mechanism. The History Change trigger fires after the URL updates but before the new route's content and its dataLayer.push have rendered, so a tag bound to it can fire before the route's dataLayer exists, and the dependable fix is a developer-emitted custom event at the moment the transition completes, not a GTM delay.
| t+ms | step | dataLayer state | tag reads |
|---|---|---|---|
| 0 | Container Loaded (gtm.js) | no ecommerce key yet | — |
| +12 | Page View trigger evaluated (on gtm.js) | no ecommerce key yet | trigger matches |
| +14 | GA4 view_item tag fires | ecommerce still absent | items: undefined · value: undefined (dropped) |
| +90 | dataLayer.push (view_item) | items[] + value now in model | too late, tag already shipped |
| +92 | DOM Ready (gtm.dom) | items[] + value present | resolves here only because the push landed first |
How to catch it
- Confirm it flickers before anything else. In GA4 DebugView (Admin > Data display > DebugView), enable debug through Tag Assistant, then load the affected page several times and click the event in the Seconds stream each time. If items or value are present on some loads and absent on others, it is a timing race. If the parameter is missing on every single load, you are looking at F-301, not this.
- In GTM Preview or Tag Assistant, select the specific firing event in the left-hand timeline rather than the Summary row (Summary shows the latest merged values and hides the race), open Variables, and confirm the Data Layer Variable for items or value shows undefined on that fire.
- In the same Preview session open the Data Layer tab and check the order. The push carrying items and value must appear before the triggering event. A push that lands after the trigger is the positive confirmation that the tag out-raced the data.
- Check the trigger type on the GA4 event tag. A page-load-family trigger (Consent Initialization, Initialization, Page View, DOM Ready, Window Loaded or All Pages) against a template- or app-pushed ecommerce object is the classic mismatch. Note where the dataLayer.push sits in the page source: below the GTM container snippet means it is not in the model at Page View.
- Verify at the layer where timing is exact. In DevTools > Network, switch on Preserve log, filter for collect (GA4 hits go to google-analytics.com/g/collect, a 204 No Content confirms transmission), reload several times and read the request payload: items and value are populated on clean fires but missing on raced fires, on a fraction of loads only. This is practitioner practice. DebugView is Google's documented path but has processing latency, so corroborate the gap here.
- Separate it from the report-only confusable. Read the outgoing /g/collect request directly. If items or value is genuinely missing from the request on the affected fires, it is F-103. If the parameter is present in every request but reads (not set) in standard reports, it was never registered as a custom dimension, which is F-601, a reporting fault that re-sequencing cannot touch.
What putting it right involves
- Re-sequence the firing. Move the GA4 event tag off the page-load-family trigger and onto a Custom Event trigger keyed to the event name of the push itself (purchase, view_item, the exact case-sensitive value of the event key). The trigger then fires only when that key appears in a dataLayer.push, so the data and the trigger are guaranteed to coexist.
- Push the event key and the values it consumes in the same object, or push the values before the event key. Only an event-key push can fire a tag, and it gives the tag the dataLayer state at the time of that push. A data-only push without an event key can be overwritten by a later push before any tag reads it, which is the same undefined-at-fire-time failure.
- Do not rely on a later page-load trigger as the cure. Moving the tag to DOM Ready or Window Loaded only helps if the push reliably completes before that moment, and template, cart, API or single-page-app pushes can still lose the race. The reliable fix is the matching Custom Event trigger, not a later page-load trigger.
- For single-page apps, do not bind to the History Change trigger. gtm.historyChange is dispatched after the URL updates but before the new route's content and its dataLayer.push have rendered, so a tag on it can fire before the route's dataLayer exists. Have the developer emit a custom event at the moment the transition completes and the data is ready, and key the trigger to that, and have that event push the route's values alongside the event key (or push them first), so the tag reads populated variables, exactly as on the page-load path.
- Flush stale keys if you also see wrong-but-present values rather than absent ones. Because the data model is merged and not reset between pushes, a prior product's items can persist into a later event. Push the offending key as undefined, or push ecommerce: null before each ecommerce push, or read the array with a Version 1 Data Layer Variable. This is the secondary nuance, distinct from the fire-before-push race, so confirm which you have before applying it.
- Verify the same way you diagnosed. Re-run several loads in DebugView and read /g/collect with Preserve log on: items and value now resolve on every fire, with the push appearing before the triggering event in the Data Layer tab. Re-sequencing recovers data that is genuinely pushed. It cannot conjure events or fields that were never sent, which stay F-301 or F-601.
Sources on file
- Google for Developers: The data layer (a push is queued and processed after pending messages and is not guaranteed to be available for the next event; use a Custom Event trigger and push values alongside the event)developers.google.com
- Google Tag Manager Help: Page view triggers (Initialization / Page View / DOM Ready / Window Loaded firing order, and using DOM Ready so the correct values are available)support.google.com
- Simo Ahava: Add the 'event' key to dataLayer pushes (only an event key can fire a tag; the event-key snapshot gives tags the dataLayer state at the time of that push)www.simoahava.com
- Analytics Mania: Google Tag Manager Custom Event trigger explained (it fires when a matching event key appears in a push; GTM can only access data available at or before the moment the event fires)www.analyticsmania.com
- Simo Ahava: Google Tag Manager's data model (pushes are recursively merged, not reset, so values persist until overwritten or reload (the stale-value nuance), plus the push-undefined flush)www.simoahava.com
- Google Analytics Help: Monitor events in DebugView (Admin > Data display > DebugView, click an event in the Seconds stream to inspect its parameters, Google's documented verification path)support.google.com
Questions on this file
How do I tell F-103 (timing race) apart from F-301 (ecommerce events partial)?
Count how often it fails. F-103 flickers: the items array exists in the dataLayer but the tag reads it before the push lands, so it arrives empty on only a fraction of loads, and re-sequencing the trigger to the push fixes it. F-301 is implemented absence: the event or the item-level field was never pushed, so it is missing on every single hit, and firing off the push cannot recover data that was never sent. Load the page several times in DebugView. Intermittent emptiness that responds to a Custom Event trigger is F-103; consistent incompleteness on every hit is F-301.
The same tag passes in testing and fails for real users. Why?
Because the outcome depends on which async path wins on a given load, and test conditions and real conditions differ. The tag's trigger and the page's dataLayer.push are racing, and the push may wait on framework hydration, an API or cart call, or a deferred script. A slow Preview session can let the push complete first while a fast cached production load fires the tag before the push arrives, or the reverse. That is why the share of affected hits is not a fixed, knowable percentage. It shifts with network, device, cache state and template.
Will moving the tag to DOM Ready or Window Loaded fix it?
Only if the push reliably completes before that page-load moment, which is exactly what you cannot count on. Template, cart, API and single-page-app pushes can still land after DOM Ready or Window Loaded. The dependable fix is a Custom Event trigger keyed to the event name of the push, because that ties the tag to the push itself rather than to a clock. Also worth knowing: Page View is not the earliest trigger; Consent Initialization and Initialization fire before it.
Why does my items array sometimes carry the wrong product rather than no product?
That is the related nuance, not the core race. GTM's data model is merged and not reset between pushes, so a key from an earlier push persists until something overwrites it or the page reloads. A prior product's items can go stale and bleed into a later event, producing a wrong-but-present value rather than an absent one. Flush it: push the key as undefined, or push ecommerce: null before each ecommerce push, or read the array with a Version 1 Data Layer Variable. The fire-before-push race produces absent values; this produces wrong ones.
Is this the same as a parameter that shows (not set) in my reports?
No, and the outgoing hit decides which it is. If the parameter is genuinely missing from the /g/collect request on the affected fires, that is F-103, the timing race. If the parameter is present in every collect request and in DebugView but reads (not set) in standard reports, it was sent correctly and simply never registered as a custom dimension, which is F-601. Read the request, not the report: missing from the request is F-103, present in the request but (not set) in reports is F-601.
Does eventCallback or eventTimeout fix the race?
No. eventCallback and eventTimeout sequence user-facing actions, such as a redirect, to run after tags have completed. They do nothing to guarantee that a variable was populated before the tag fired, which is the actual problem here. Reaching for them on F-103 sequences the wrong end of the flow. The lever is the trigger: bind the tag to the push with a Custom Event trigger so the data is present at fire time.