Solving the GA4 '(not set)' Mystery After Shopify Server-Side Tracking Goes Live

Alright, store owners, let's talk about something that's probably given more than a few of you a minor heart attack: that sudden, alarming surge of "(not set)" users in your GA4 reports right after you flipped the switch on server-side tracking for your Shopify store. It looks like a bot invasion, doesn't it? Like your analytics just broke. But as we recently discussed in a lively community thread, it's usually not a bot wave at all – or at least, not entirely. It's a fundamental shift in how your data is collected, and understanding it is key to getting your reports back on track.

Understanding the '(not set)' Phenomenon

When you move to server-side tracking, especially with Shopify's evolving pixel landscape, a few things change under the hood that can lead to this "(not set)" flood. Our community expert, koncz.szabi, laid out the core issues brilliantly:

  • Geography Gone Missing: Country = (not set)
    When events hit GA4 via the Measurement Protocol from your server, GA4 tries to figure out the user's location from the IP address it receives. If you don't explicitly pass the ip_override field with the *customer's* IP, GA4 sees your *server's* IP. Result? It can't determine the user's actual country, so you get "(not set)".
  • Identity Crisis: Landing page & Session source = (not set)
    This is the big one. Many users, especially those using ad-blockers (uBlock, AdGuard), privacy browsers (Brave, Firefox with strict protection), or Safari's content blockers, never load Google's client-side tag (googletagmanager.com). Your Shopify first-party pixel, however, usually still loads for them. When your server-side tool sends these events, they arrive without the familiar _ga cookie that GA4 uses to identify returning users and link them to previous sessions, landing pages, or campaign data. So, for GA4, these are "new" users with no prior context, hence the "(not set)" for landing page and source.
  • The Real Bots Who Slip Through
    Yes, some actual crawlers are in there too. GA4's built-in bot filtering relies on the IP and user agent of the *request it receives*. If that request is coming from your server, and the crawler is sophisticated enough to run JavaScript (like headless Chrome), it might get a pass because GA4 sees your server's trusted details, not the bot's.

The good news? Much of this "(not set)" traffic represents *real customers* you were always getting, but now you're finally seeing their interactions, albeit without proper attribution. The challenge is separating them from the actual crawlers and fixing the missing context.

Community-Driven Solutions: Getting Your Data Clean

The thread brought forward some fantastic, actionable advice. Here’s a synthesis of the best strategies to tackle this:

1. Foundational Fixes: Correcting Identity & Geography

Before you dive into complex segmentation, let’s make sure the basic information is being passed correctly.

  1. Pass the Real IP & User Agent:
    • For Geography: Ensure your server-side setup uses the ip_override parameter to send the *customer's* IP address with every Measurement Protocol hit. This allows GA4 to correctly determine their location.
    • For Bot Filtering: Crucially, pass the *customer's* original User-Agent string as the User-Agent header on your Measurement Protocol requests. As lumine pointed out, GA4's bot checks run against the request it receives. If it sees your server's User-Agent, it can't effectively filter bots. Sending the original helps GA4 identify known crawlers.
  2. Ensure Stable User & Session IDs:
    • Persist a First-Party ID: As clickfromai and lumine emphasized, stop minting a new client_id for every single event. Instead, persist a stable, first-party ID (a unique identifier for your user) in localStorage or a cookie. Reuse this ID across pages and sessions. This is vital for blocked-browser humans to maintain their identity.
    • Maintain a Session ID: Similarly, keep one session_id for the visit, rolling it over after 30 minutes of inactivity. Send page_view events first with page_location and page_referrer, then other browse events. This helps GA4 properly group events into sessions.

2. The Deep Dive: Splitting Real Users from Bots/Crawlers

This is where it gets clever. Once you've implemented stable IDs, you can start to differentiate behavior within that "(not set)" bucket.

  1. Implement "Carried vs. Fresh" Storage Persistence:

    This was a key insight from lumine. Add a custom parameter to your events that indicates the status of the user's ID:

    • "Fresh": The ID was newly minted for this hit (no prior ID found).
    • "Carried": The ID was successfully read back from storage (meaning this browser had a previous interaction).
    • "Failed Read": The storage read itself failed.

    Why this works: A human user with an ad-blocker will likely show "fresh" on their very first pageview, but "carried" on all subsequent pageviews within the same visit. A crawler, however, often runs in a fresh context for every page fetch and will almost *always* show "fresh". The ratio of "carried" to "fresh" within your "(not set)" segment becomes a powerful indicator of the human vs. bot split.

    Caveats: Safari's localStorage has a 7-day cap, so "carried" won't persist indefinitely. Also, a genuinely single-page human visitor will look identical to a crawler under this test. To account for this, lumine suggested looking at your normal single-page session rate on traffic *outside* the "(not set)" bucket to establish a baseline floor.

  2. Use Behavioral Segmentation:

    Once you have stable IDs, you can segment your "(not set)" users based on their behavior. Sessions with only one event and an engagement_time_msec of zero are strong candidates for crawlers. Those with multiple events, scrolling, or longer engagement are more likely human, even if their initial ID was "fresh."

  3. Consider a google_tag_loaded Custom Dimension:

    While discussed and refined in the thread, adding a custom dimension like google_tag_loaded=true/false can still be useful. It won't *split* the "(not set)" bucket internally (because both ad-blockers and crawlers would show false), but it clearly delineates the boundary of traffic where Google's client-side tag *didn't* run, allowing for separate comparisons.

3. Navigating Shopify Pixel Sandbox Quirks

If you're building custom solutions within the Shopify pixel sandbox, there's a specific technical hurdle to be aware of.

  1. The localStorage Promise Challenge:
    koncz.szabi highlighted that browser.localStorage.getItem returns a promise, resolved in the top frame. This means it's an asynchronous operation with a round trip out of the worker. If you dispatch events *before* this promise settles, you won't have the existing ID, leading to new IDs being minted unnecessarily.
  2. Buffering Events & fetch with keepalive: true:
    The solution, as lumine explained, is to buffer your events. Don't dispatch them immediately. Instead, hold them and flush them *after* the localStorage read resolves. This ensures the correct, persistent ID is available. Furthermore, to prevent these buffered hits from being lost if the visitor navigates away quickly, use fetch with keepalive: true instead of the deprecated browser.sendBeacon. This ensures your buffered hits survive the navigation.
  3. Check Your customer_privacy Block:
    lumine also brought up the customer_privacy block in your extension's toml file. If analytics is declared true, your pixel manager might only load the pixel for consented visitors. This means your "(not set)" bucket might already be pre-filtered, affecting your analysis of the "carried vs. fresh" ratio. Make sure you understand your pixel's consent declaration.

Initial Sanity Checks After Launch

When you first roll out server-side tracking, it's normal for things to look a bit wonky. Before diving deep into fixes, do these quick checks:

  • Isolate the Bucket: In GA4 Explore, exclude Country = (not set). Does what remains look like your normal store traffic? If so, you've likely isolated the problem, not lost your data entirely.
  • Give it Time: Attribution can take a little while to settle. Leave a partial day's data alone for a day or two; sometimes "Unassigned" shrinks on its own as GA4 processes more data.
  • Annotate Your Launch: Make a clear note of the server-side tracking launch date. Exclude that period from any baseline comparisons going forward, as your data collection method has fundamentally changed.

The journey to clean GA4 data with server-side tracking isn't always straightforward, especially with the nuances of Shopify's pixel environment. But as this community discussion shows, by understanding the underlying causes of "(not set)" and implementing these sophisticated strategies for ID management and event buffering, you can regain clarity and confidence in your analytics. It's about moving from guessing to measuring, and ultimately, making better decisions for your store.

Share:

Start with the tools

Explore migration tools

See options, compare methods, and pick the path that fits your store.

Explore migration tools