You’ve spent hours configuring Google Tag Manager. Every variable is mapped. Every trigger fires perfectly in Preview mode. You test, retest, and finally hit publish with confidence.
The next morning, you open GA4 reports.
A large share of your custom dimensions show “(not set)”.
Welcome to one of the most frustrating debugging experiences in web analytics: the invisible timing issue that only appears in production.
I recently debugged this exact problem on a multi-country e-commerce project (anonymised). Their GTM setup was textbook perfect. Their dataLayer implementation followed all best practices. And yet, hundreds of thousands of events were landing in GA4 with missing parameters.
The culprit? Race conditions between dataLayer initialization and GTM container execution.
In this article, I’ll walk you through why this happens, how to diagnose it properly, and most importantly, how to fix it permanently. This isn’t theory—these are battle-tested solutions from real production environments.
What “(not set)” Actually Means in GA4
Before we dive into fixes, let’s clarify what we’re dealing with.
When GA4 shows “(not set)” for a custom dimension, it usually means the event reached GA4 without that parameter (or with an empty value): the variable was empty when the tag fired.
GA4 has one placeholder for this, “(not set)”. Note it also appears for the first 24 hours after you register a new custom dimension (Google: [GA4] The value (not set)).
Why does GA4 show a row instead of dropping the event?
Once a parameter is registered as a custom dimension, every event that arrives without it is shown as “(not set)” in that dimension. This actually helps you spot tracking issues—you can see that events ARE firing, but data isn’t making it through.
The real-world impact is brutal:
- Your user segmentation breaks (can’t segment by undefined user_type)
- E-commerce attribution falls apart (transaction_id showing (not set))
- Custom dimensions become useless (many events with missing context)
- Your dashboards fill with “(not set)” rows that nobody knows how to interpret
- Stakeholders lose trust in the data (“why is everything broken?”)
The worst part? This often goes unnoticed for weeks or months. Your tracking appears to work—events are firing, hit counts look right. But you’re silently losing granularity and accuracy.
The 3 Root Causes I See Every Week
After debugging dozens of these cases, I’ve identified three primary causes. Let’s break them down with real examples.
Cause #1: Race Condition Between dataLayer and GTM Container
This is the most common culprit, and it’s insidious because it works perfectly in testing environments.
Here’s what happens:
<!DOCTYPE html>
<html>
<head>
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXX');</script>
<!-- End Google Tag Manager -->
</head>
<body>
<!-- Your dataLayer init happens here, AFTER GTM loads -->
<script>
window.dataLayer = window.dataLayer || [];
// This data comes from an API call
fetch('/api/user-data')
.then(response => response.json())
.then(data => {
window.dataLayer.push({
'userID': data.user_id,
'userType': data.user_type,
'membershipLevel': data.membership
});
});
</script>
</body>
</html>
What’s wrong here?
The GTM container loads in the <head>. It immediately starts executing tags. Your “All Pages” trigger fires a GA4 page_view event.
At that exact moment, the dataLayer exists (because GTM creates it if it doesn’t), but it’s empty. The API call hasn’t returned yet. Your userID, userType, and membershipLevel variables read as undefined.
GTM sends the event to GA4 with empty parameters.
300 milliseconds later, your API responds. The dataLayer gets populated. But it’s too late—the page_view event already fired.
Why doesn’t this happen in Preview mode?
Because when you’re testing locally or on fast connections:
- Your local server responds in 10ms, not 300ms
- Browser cache makes subsequent loads instant
- No network latency, no packet loss
- The timing “just works” by accident
But in production, with real users on 3G connections, CDN latency, and API calls that take 500ms+? The race condition becomes obvious.
Real example (anonymised):
On one project, the GTM container was hosted on Google’s CDN (fast). The dataLayer initialization script was hosted on the site’s own origin (slower). On desktop with fiber internet, both load almost simultaneously. On mobile 3G, the GTM container consistently loaded 400-800ms before that script executed.
Result: most of the empty values came from mobile users. Desktop users? Perfectly fine.
Cause #2: Reading Variables at the Wrong Event Timing
Even if your dataLayer is initialized before GTM loads, you can still hit timing issues based on WHEN variables are read during the event lifecycle.
The key concept: a value pushed to the dataLayer stays in GTM’s data model until it is overwritten or the page reloads.
Consider this sequence:
// Page loads
window.dataLayer = window.dataLayer || [];
// Initial page data (persists)
window.dataLayer.push({
'pageType': 'product',
'pageCategory': 'electronics'
});
// GTM fires page_view event
// ✓ pageType = 'product'
// ✓ pageCategory = 'electronics'
// User adds item to cart (event-specific data)
window.dataLayer.push({
'event': 'add_to_cart',
'ecommerce': {
'items': [{
'item_name': 'Smartphone X',
'price': 699
}]
}
});
// Later, user views another page (SPA navigation)
// ✗ ecommerce data STILL PERSISTS in dataLayer
// If not cleared, next page_view includes wrong ecommerce data
The problem:
Data Layer Variable Version 1 and Version 2 differ in how they read dots: V2 reads ecommerce.items as nested objects, V1 treats a.b.c as one key. Either way, a value stays in GTM’s data model until it is overwritten or the page reloads. In an SPA, that means old values survive “page changes”.
Common scenario I see:
E-commerce sites that push transaction data to the dataLayer AFTER the page_view event fires. They have this sequence:
- Page loads → page_view fires (transaction_id = (not set))
- 50ms later → CMS populates transaction data
- Custom event fires → transaction_id now populated correctly
The page_view event has already gone to GA4 with an empty transaction_id. The custom event works fine. So you get a split-brain situation where some events have the data and others don’t.
Version 1 vs Version 2 Data Layer Variables:
In GTM, when you create a Data Layer Variable, you choose the version:
- Version 1: does not interpret dots (legacy).
- Version 2: reads nested keys with dot notation and merges successive pushes (recommended). Source: Google Tag Manager: user-defined variable types.
Most modern implementations should use Version 2, but I still see plenty of Version 1 variables in production that cause these issues.
Cause #3: dataLayer Not Cleared Between Pushes
This is especially problematic for Single Page Applications (SPAs) or sites with dynamic content loading.
The scenario:
// User views Product A
window.dataLayer.push({
'event': 'product_view',
'productID': '12345',
'productName': 'Smartphone X',
'productPrice': 699
});
// User navigates to Product B (SPA, no page reload)
window.dataLayer.push({
'event': 'product_view',
'productID': '67890',
'productName': 'Laptop Y',
'productPrice': 1299
});
// Keys pushed again are overwritten. The real risk is keys that Product B
// does NOT push (e.g. a productVariant only Product A had): they keep Product A's value.
Why does this matter for “(not set)” values?
Because if your implementation EXPECTS a value to be cleared but it’s not, you might set up logic like:
// Attempt to clear by setting to empty string
window.dataLayer.push({
'productID': '',
'productName': '',
'productPrice': ''
});
Prefer undefined to clear a key. Test null and '' in Tag Assistant before relying on them: an empty value ends up as “(not set)” in GA4 reports.
The correct way to clear:
window.dataLayer.push({
'productID': undefined,
'productName': undefined,
'productPrice': undefined
});
Or push the data layer reset method (see Fix #2).
Real-world example:
A travel booking site I worked with had a multi-step form. Each step pushed form data to the dataLayer. They assumed that pushing NEW data would overwrite OLD data.
It didn’t.
Step 3 of the form still had fields from Step 1 in the dataLayer, causing duplicate or conflicting data to be sent. Some variables showed correct values, others showed stale data, and some came out empty because validation logic failed when it saw conflicting values.
My 5-Step Debugging Checklist
When I’m called in to debug “(not set)” values, I follow this exact process. It catches most issues.
Step 1: Verify dataLayer Position in Source Code
What to check:
Open your page source (View > Developer > View Source in most browsers) and find where the dataLayer is initialized.
The correct order MUST be:
<!-- 1. dataLayer initialization FIRST -->
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'userID': '12345',
'pageType': 'product'
});
</script>
<!-- 2. GTM container SECOND -->
<script>(function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-XXXXXX');</script>
Common mistakes:
WordPress sites using plugins: Many GTM plugins inject the container in the <head> automatically, but your dataLayer initialization might be in the theme’s footer or in a separate plugin that loads later.
Shopify themes: if the <script> that pushes your Liquid values to the dataLayer is placed in the <body>, it runs after the GTM snippet in the <head>. Move that script above the GTM snippet.
Custom builds with async loading: Developers love to async-load everything for performance. But if your dataLayer script is loaded async AFTER the GTM container script, you’ve created a race condition.
How to fix:
Move your dataLayer initialization to the earliest possible point in the <head>, BEFORE the GTM snippet. Use an inline script (not an external async file) placed above the GTM snippet so it runs first.
Step 2: Use GTM Preview with Network Throttling
GTM Preview mode is great, but it lies to you if you don’t simulate real network conditions.
How to enable network throttling in Chrome:
- Open Chrome DevTools (F12 or Cmd+Opt+I)
- Go to the Network tab
- Look for the throttling dropdown (usually says “No throttling”)
- Select “Fast 3G” or “Slow 3G”
- Reload the page with GTM Preview mode active
What to look for:
Watch the Timeline in GTM Preview. You should see events firing in order:
- Container Loaded
- Page View
- (Your custom events)
Now look at the Variables tab for each event. Check if your custom Data Layer Variables show values or undefined.
With throttling enabled, you’ll often see:
- Page View fires at T+0ms → variables are undefined
- A few hundred milliseconds later, variables populate
- But the Page View already sent to GA4
This exposes the race condition that fast connections hide.
Pro tip:
Test on an actual mobile device with 3G enabled, not just desktop throttling. Real-world conditions include:
- Packet loss
- Connection dropping and reconnecting
- DNS lookup delays
- TLS handshake latency
Desktop throttling simulates bandwidth limits but not these other factors.
Step 3: Inspect dataLayer State in Real-Time
Method 1: Console Commands
Open Chrome DevTools console and type:
console.log(window.dataLayer);
You’ll see an array of all dataLayer pushes. Expand each one to see what data was pushed at what time.
Look for:
- Is the dataLayer initialized before GTM?
- Are your expected variables in the initial push?
- Are variables being pushed AFTER events fire?
Method 2: dataslayer Chrome Extension
Install the dataslayer extension (free). It gives you a clean UI showing:
- All dataLayer pushes in chronological order
- Current state of all variables
- GTM tags that fired and what data they used
Method 3: Add Timestamps
Modify your dataLayer pushes temporarily to include timestamps:
window.dataLayer.push({
'event': 'add_to_cart',
'productID': '12345',
'_timestamp': Date.now()
});
Then check in GTM Preview which events have timestamps and when they occurred relative to tag fires.
Step 4: Check Variable Configuration in GTM
Open GTM and inspect your Data Layer Variables.
Key settings to verify:
1. Data Layer Variable Version:
- Variables > Your Data Layer Variable > Data Layer Version
- Should almost always be “Version 2” for modern implementations
- If Version 1, consider switching (but test thoroughly first)
2. Data Layer Variable Name:
- Must EXACTLY match the key in your dataLayer push
- Case-sensitive:
userID≠userid≠UserID - Dot notation for nested values:
ecommerce.transaction_id
3. Default Value:
- Should typically be left blank
- Setting a default value can MASK issues (you won’t see (not set), but you also won’t know when data is missing)
- Only use defaults if you have a specific fallback strategy
4. Format Value:
Some variables have “Format Value” options (lowercase, uppercase, etc.). Verify these aren’t causing issues:
- Converting numbers to strings unintentionally
- Trimming whitespace that’s actually significant
- Regex replacements that fail and return empty string
Step 5: Test with Real User Conditions
Real devices matter:
- Test on actual iOS Safari (not just desktop Safari)
- Test on actual Android Chrome (not just desktop Chrome mobile emulation)
- Test on slow WiFi, not just throttled desktop
Different browsers have different timing quirks:
- Safari has different script execution timing than Chrome
- Firefox handles async script loading differently
- Edge has its own peculiarities
Ad blockers and privacy tools:
- Test with uBlock Origin enabled
- Test with Privacy Badger enabled
- Test with Brave browser (blocks trackers by default)
Sometimes GTM itself gets blocked, so your dataLayer is fine but no tags fire at all. Then the events are missing entirely (lower counts), which is a different symptom from “(not set)” parameters.
Check from different geographic regions:
If your CDN serves GTM from different edge nodes based on user location, test from:
- North America
- Europe
- Asia-Pacific
CDN latency varies by region, which can expose timing issues in some markets but not others.
The Fixes That Actually Work
Now let’s get to solutions. These are ordered from simplest to most robust.
Fix #1: Use Custom Events, Not Auto-Events
The problem with automatic pageview triggers:
GTM’s built-in “All Pages” trigger runs on the gtm.js event pushed by the snippet. It only sees values pushed before the snippet. Anything pushed later is only visible to later events.
The solution: Explicit custom events
// Don't rely on automatic pageview
// Instead, push a custom event when data is ready
window.dataLayer = window.dataLayer || [];
// Initialize with data
window.dataLayer.push({
'userID': '12345',
'userType': 'premium',
'pageCategory': 'product'
});
// THEN fire the event
window.dataLayer.push({
'event': 'dataReady'
});
In GTM, change your Page View tag trigger from “All Pages” to “Custom Event: dataReady”.
Why this works:
You control exactly when the event fires. No race conditions. The data is guaranteed to be in the dataLayer before the event triggers tags.
Bonus: You can stack this for different data types
// Core page data ready
window.dataLayer.push({'event': 'pageDataReady'});
// User data ready (might load from API)
fetch('/api/user')
.then(res => res.json())
.then(data => {
window.dataLayer.push({
'userID': data.id,
'userType': data.type
});
window.dataLayer.push({'event': 'userDataReady'});
});
// Product data ready (might load from CMS)
window.dataLayer.push({'event': 'productDataReady'});
Then in GTM, configure different tags to fire on different custom events based on what data they need.
Fix #2: Clear dataLayer Values Explicitly
For SPAs and dynamic content, always clear old values:
// User views Product A
window.dataLayer.push({
'event': 'product_view',
'productID': '12345',
'productName': 'Smartphone X',
'productPrice': 699
});
// User navigates to Product B
// FIRST: Clear old product data
window.dataLayer.push({
'productID': undefined,
'productName': undefined,
'productPrice': undefined
});
// THEN: Push new product data
window.dataLayer.push({
'event': 'product_view',
'productID': '67890',
'productName': 'Laptop Y',
'productPrice': 1299
});
Why undefined and not null or ''?
undefined: GTM treats this as “variable doesn’t exist”, won’t send the parameternull: GTM might send “null” as a string value''(empty string): GTM might send this as an empty value, showing up as “(not set)” in GA4
Alternative: Use GTM’s reset method
In advanced scenarios, you can use:
window.dataLayer.push(function() {
this.reset();
});
This calls the data layer reset method (pushed as a function to dataLayer, not through gtag). It clears the values held in GTM’s data model for this page. Source: Google: The data layer.
Fix #3: Add a Guard Condition in GTM Triggers
For triggers that need specific data, add a condition:
- Create a trigger (e.g., Custom Event: add_to_cart)
- Add a condition:
{{DLV - productID}}does not equalundefined
Example configuration:
Trigger Type: Custom Event
Event name: add_to_cart
This trigger fires on: Some Custom Events
Conditions:
- Event equals add_to_cart
- {{DLV - productID}} does not equal undefined
- {{DLV - productName}} does not equal undefined
Why this works:
GTM triggers cannot wait for data: the condition only filters at the moment the event is processed. When the value is missing, the tag is skipped (not delayed) for that event. If the value may never come, push the event anyway after a timeout in your own page code (setTimeout) and send a fallback value.
Caveat:
Overusing this can make your GTM setup complex and fragile. Prefer fixing the root cause (dataLayer timing) over band-aid trigger conditions.
Fix #4: Centralize dataLayer Initialization in <head>
The gold standard approach:
<!DOCTYPE html>
<html>
<head>
<!-- Step 1: Initialize dataLayer with ALL critical data -->
<script>
window.dataLayer = window.dataLayer || [];
// Inline critical data (from server-side rendering)
window.dataLayer.push({
'userID': '<?php echo $user_id; ?>',
'userType': '<?php echo $user_type; ?>',
'pageType': 'product',
'pageCategory': 'electronics'
});
</script>
<!-- Step 2: Load GTM container -->
<script>(function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-XXXXXX');</script>
</head>
<body>
<!-- Page content -->
</body>
</html>
Key principles:
- Render critical dataLayer values server-side (PHP, Node.js, Python, whatever your backend is)
- This guarantees the data exists BEFORE GTM loads
- No async API calls, no race conditions
- For data that MUST be async (third-party APIs), use custom events as in Fix #1
When you can’t do server-side rendering:
If you’re on a static site or can’t control server-side rendering:
<head>
<script>
window.dataLayer = window.dataLayer || [];
// Pull from localStorage or cookies (synchronous)
var userID = localStorage.getItem('user_id');
var userType = localStorage.getItem('user_type');
if (userID && userType) {
window.dataLayer.push({
'userID': userID,
'userType': userType
});
}
// Fire custom event when data is ready
window.dataLayer.push({'event': 'userDataReady'});
</script>
<script>(function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-XXXXXX');</script>
</head>
This pulls data from localStorage (synchronous operation) before GTM loads.
Advanced: Multi-Container Nightmares
I’m going to be blunt: most sites don’t need multiple GTM containers.
But if you DO have multiple containers (common in large enterprises), timing issues multiply exponentially.
Why multi-container makes this worse:
Each container loads independently. Each reads the dataLayer at ITS OWN execution time.
Example disaster scenario:
- Container 1 (Core Tags) loads at T+0ms → dataLayer is empty → sends events with empty values
- Container 2 (Marketing Tags) loads at T+200ms → dataLayer now populated → sends events with correct values
- Container 3 (Analytics Tags) loads at T+400ms → same data as Container 2
Now you have the same user, same session, but different containers sending conflicting data to GA4.
The fix (if you must use multiple containers):
Centralize your dataLayer initialization in a SINGLE script that loads before ALL containers:
<head>
<!-- Load dataLayer init script FIRST -->
<script src="/js/datalayer-init.js"></script>
<!-- THEN load all containers -->
<script>(function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-CORE');</script>
<script>(function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-MARKETING');</script>
<script>(function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-ANALYTICS');</script>
</head>
In /js/datalayer-init.js:
window.dataLayer = window.dataLayer || [];
// Populate all required data
window.dataLayer.push({
'userID': getUserID(), // Your function
'userType': getUserType(), // Your function
'pageType': getPageType(), // Your function
// ... all other critical variables
});
// Fire event when ready
window.dataLayer.push({'event': 'dataLayerReady'});
Then in ALL containers:
Configure your tags to fire on the custom event dataLayerReady instead of page load.
This ensures all containers wait for the centralized dataLayer to be fully populated before executing tags.
My controversial opinion:
If you find yourself fighting multi-container timing issues for more than a day, seriously reconsider whether you need multiple containers.
In most cases, proper use of:
- Workspaces (for team collaboration)
- Folders (for organization)
- Naming conventions (for clarity)
- User permissions (for governance)
…will solve the same problems that people think require multiple containers, without the debugging nightmare.
The Definitive Debugging Checklist
Print this out and tape it to your monitor:
Pre-Flight Checks:
- dataLayer initialized in
<head>BEFORE GTM container snippet - All critical variables populated synchronously (server-side or localStorage)
- GTM container snippet placed right after the dataLayer initialisation (keep Google’s official async snippet unchanged)
GTM Configuration:
- Data Layer Variables use Version 2 (unless you have a specific reason for V1)
- Variable names EXACTLY match dataLayer keys (case-sensitive)
- No default values set (unless intentional fallback strategy)
- Triggers use custom events, not just “All Pages”
Testing Checklist:
- Test in GTM Preview mode with network throttling (Fast 3G minimum)
- Inspect
window.dataLayerin console—verify data exists before events fire - Test on real mobile devices (iOS Safari, Android Chrome)
- Test with ad blockers enabled
- Review GA4 DebugView in real-time while testing
- Check production reports for (not set) spikes after deployment
Production Monitoring:
- Set up GA4 custom insights (with email alerts) on the share of “(not set)” values
- Monitor dataLayer errors in console (set up error tracking)
- Regularly audit reports for “(not set)” values
- Document known timing constraints in your implementation
Tools I Use Daily
For debugging dataLayer:
- dataslayer (Chrome extension) – Clean UI for inspecting dataLayer state
- GTM Preview mode – Essential, but always enable network throttling
- Chrome DevTools Network tab – See exact timing of script loads
- GA4 DebugView – Real-time validation of events and parameters
For production monitoring:
- GA4 custom insights – Email alert when “(not set)” values spike above a threshold (Google: custom insights)
- Sentry or LogRocket – Catch JavaScript errors that might break dataLayer
- Looker Studio – Dashboard showing % of events with empty parameters over time
For advanced analysis:
- BigQuery – Export GA4 data and query for “(not set)” patterns
- SQL queries – Identify which events/parameters have highest “(not set)” rates
When to Give Up and Refactor
Sometimes, the cleanest solution is to admit your implementation is fundamentally broken and start over.
Signs it’s time to refactor:
- You’re fighting timing issues for more than 2 days with no progress
- Multiple containers with inter-dependencies that nobody fully understands
- dataLayer populated by 5+ different scripts from different teams
- A large share of events consistently show “(not set)” despite fixes
- Your GTM container has 200+ tags and nobody knows what half of them do
The refactor approach:
- Audit current state: Export all tags, triggers, variables from GTM. Document what actually needs to fire.
- Design new dataLayer schema: Decide what variables you ACTUALLY need. Don’t carry forward legacy cruft.
- Server-side render critical data: Work with developers to populate dataLayer on the server before page renders.
- Single source of truth: One script, one place, initializes the dataLayer. No exceptions.
- Custom events for async data: If data must load async, use explicit custom events to signal readiness.
- Clean up GTM: Remove unused tags. Consolidate redundant variables. Simplify triggers.
- Test ruthlessly: Throttled networks, real devices, ad blockers, edge cases.
- Document everything: Future you (and future team members) will thank you.
Conclusion
“(not set)” custom dimensions in GA4 are most often a timing issue, not a configuration issue.
Your GTM setup can be perfect. Your dataLayer schema can be well-designed. But if the dataLayer isn’t fully populated BEFORE tags fire, you’ll get “(not set)” values.
The fixes are straightforward:
- Initialize dataLayer BEFORE GTM container loads
- Populate critical data synchronously (server-side or from cookies/localStorage)
- Use custom events for async data, don’t rely on automatic pageview triggers
- Test with network throttling to expose race conditions
- Consider whether you really need multiple GTM containers
Remember: the goal isn’t elegant architecture. The goal is accurate data.
If your current implementation is fighting you, simplify. Move dataLayer initialization earlier. Use custom events. Test on slow networks.
And if all else fails, refactor. Sometimes the cleanest path forward is admitting the current implementation is broken and building it right from scratch.
Want to dive deeper into tracking implementations? Read the Tag Commander version of this problem: Tag Commander multi-container: a complete timing breakdown.
Got questions or want to share your own debugging war stories? Hit me up on LinkedIn or leave a comment below.
Related reading
- Set up a Google Analytics tracking in high level steps
- GA4 Events Explained – Full Overview + Ecommerce Funnel with Real Examples
- Understanding Google Signals in Google Analytics 4: How It Works and a Use Case with Demographic Data