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:

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:

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:

  1. Page loads → page_view fires (transaction_id = (not set))
  2. 50ms later → CMS populates transaction data
  3. 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:

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:

  1. Open Chrome DevTools (F12 or Cmd+Opt+I)
  2. Go to the Network tab
  3. Look for the throttling dropdown (usually says “No throttling”)
  4. Select “Fast 3G” or “Slow 3G”
  5. 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:

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:

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:

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:

Method 2: dataslayer Chrome Extension

Install the dataslayer extension (free). It gives you a clean UI showing:

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:

2. Data Layer Variable Name:

3. Default Value:

4. Format Value:

Some variables have “Format Value” options (lowercase, uppercase, etc.). Verify these aren’t causing issues:

Step 5: Test with Real User Conditions

Real devices matter:

Different browsers have different timing quirks:

Ad blockers and privacy tools:

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:

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 ''?

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:

  1. Create a trigger (e.g., Custom Event: add_to_cart)
  2. Add a condition: {{DLV - productID}} does not equal undefined

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:

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:

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:

…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:

GTM Configuration:

Testing Checklist:

Production Monitoring:

Tools I Use Daily

For debugging dataLayer:

For production monitoring:

For advanced analysis:

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:

The refactor approach:

  1. Audit current state: Export all tags, triggers, variables from GTM. Document what actually needs to fire.
  2. Design new dataLayer schema: Decide what variables you ACTUALLY need. Don’t carry forward legacy cruft.
  3. Server-side render critical data: Work with developers to populate dataLayer on the server before page renders.
  4. Single source of truth: One script, one place, initializes the dataLayer. No exceptions.
  5. Custom events for async data: If data must load async, use explicit custom events to signal readiness.
  6. Clean up GTM: Remove unused tags. Consolidate redundant variables. Simplify triggers.
  7. Test ruthlessly: Throttled networks, real devices, ad blockers, edge cases.
  8. 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:

  1. Initialize dataLayer BEFORE GTM container loads
  2. Populate critical data synchronously (server-side or from cookies/localStorage)
  3. Use custom events for async data, don’t rely on automatic pageview triggers
  4. Test with network throttling to expose race conditions
  5. 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

Sources & Further Reading

Not sure your own tracking is right?

Book a call Or start with the free analytics audit →