I Was Definitely Logged In. Why Is Everything Empty?
I had just finished setting up front-end tracking. I'd planted all the code to push values, user ID, membership status, marketing consent, into the dataLayer (the channel a web page uses to pass data to analytics tools). Time to verify. I opened GTM (Google Tag Manager) Preview and logged into the site.
Then I opened the GA4 Configuration tag (the core tag that initializes analytics when the site loads) and looked at the user properties section. This is what it showed.
| Name | Value |
|---|---|
user_id | undefined |
membership_status | undefined |
is_logged_in | undefined |
device_id | undefined |
marketing_consent | undefined |
I was clearly logged in. I was authenticated as a member, yet not a single value identifying that member was present. Did I plant the code wrong? Did I misspell a variable? I stared at that screen for a long time. That was the beginning. This post is the story of how that undefined turned out to be not a bug, but a mirror reflecting exactly how GA4 works.
The Critical Clue: page_category Was Empty Too
The variable names were correct. The code was writing values. So why. Looking again at the screen, I noticed page_category above the user properties was also undefined. That value comes from a completely different source than user data, it holds which category a page belongs to, and it comes in as the page renders.
Two values from entirely different sources, both empty. They shared one thing in common: both arrive "later." That's when my suspicion shifted for the first time. What if the values weren't missing, what if the tag had fired before the values arrived? I printed window.dataLayer in the browser console and spread out the timeline. The picture appeared all at once.
Why does it arrive late? The config tag fires at gtm.js, the earliest possible moment when the container loads. Confirming login state, on the other hand, happens after the front end (an SPA like React) has rendered the page and verified authentication. That's async. The code that knows who the user is doesn't push values into the dataLayer until after the page is up. The undefined the config tag saw was an accurate snapshot of that exact moment. The value wasn't wrong, the photo was taken too early.
Two Dead Ends: Stuffing the Config Tag, Then Creating a New Event
I knew the cause, so I just needed to fix it. But here I took two wrong turns. Embarrassing as they are, I'm keeping them, partly to avoid wandering the same path again, and partly because these two detours are actually the fastest route to the core concepts.
First wrong turn. "Then I just need to set the config tag values more carefully." No. Values set in the config tag (GA4's Google tag) are read exactly once, at the moment the tag fires, and then they're frozen. Static. Since it fires before login, no matter how carefully you set those values, they'll be undefined forever. It can't be fixed without changing where you put the values.
Second wrong turn. "Then I'll create a new event that signals login context is ready and fire the tag off that." It sounded reasonable. I spent a while designing it. But when I checked against real-world practice, it was overkill. No separate event is needed, because the dataLayer accumulates. This "accumulation" is the second most important concept in this post, so let me pin it down precisely.
Cumulative dataLayer: Values That Come In Never Disappear
The dataLayer is not a variable that gets overwritten, it's a model that accumulates and merges. Expanding the array in the console shows: anonymous context comes in first (user_id: undefined), then once login is confirmed, an object containing user_id: '1' is pushed on top. GTM's dataLayer variables read the latest value from this accumulated state. So once a login value is pushed, every event tag that fires afterward reads that value directly. I didn't need a new event, I just needed to move where I was reading the value.
There's a hidden trap here, so let me flag it now. There are two versions of the dataLayer variable. The default Version 2 merges and holds accumulated values, but Version 1 treats any key absent from that push as undefined. If you build the variable as Version 1, the accumulation benefit disappears and values look empty again. Variables for user properties must be Version 2. Without knowing this, you'll hit another wall: "I definitely moved it to the event settings, so why is it undefined again?"
Only after two wrong turns did the core insight appear. I had kept asking "which tag do I put this in?" But user properties were never meant to work that way.
The Turning Point: User Properties Attach to People, Not Tags
This is the heart of the post. GA4 data has a concept called scope. Without understanding it, you'll keep fixing the wrong thing indefinitely. The GA4 official docs define user properties as "attributes that describe groups of your user base." They describe people, not actions. Put them side by side with event parameters and the nature of each becomes completely clear.
Here comes the decisive property. Once a user property arrives in GA4, it is saved to that person and automatically attached to all subsequent events. It persists until the value is changed. So you don't need to include it in every single tag. Once the value "member" arrives during the login event, GA4 stamps that person and handles the rest automatically.
Only then did I fully understand why the initial undefined wasn't a problem. The config tag being empty is fine. Once the login event delivers the value once, that's all it takes. The config tag screen I had stared at for days was a place values could never enter in the first place. I was standing at the wrong window, complaining it was dark inside.
So Where Do You Put It: A Single Event Settings Variable
Move where you read the value from "the config tag that fires once" to "events that re-read on every fire." That doesn't mean copying and pasting the same values into every tag. The GTM official docs provide exactly the right tool for this: the Event Settings Variable. Build it once and have every GA4 event tag reference it.
The key is this one line from the official docs: "Unlike values set by a configuration tag, the values of an Event Settings Variable are dynamically recalculated each time an event is sent. They are not tied to static values." Dynamic recalculation is the root fix for the problem we started with.
Here's how to set it up. In GTM, create one variable of type "Google tag: Event Settings." In the User Properties section, wire up is_logged_in, membership_status, device_id, and marketing_consent, each as a dataLayer variable. Just once. Then in each GA4 event tag, simply select this variable in the Event Settings Variable field. If you ever need to change a property later, updating that one variable propagates to every tag.
Does that mean you don't need to include anything in the config tag at all? There's a nuance here. The short answer: having both is the standard practice. For flows like this session, entering logged out, then logging in, the Event Settings Variable handles it. But for returning visits where the user is already logged in, the config tag may fire when values are already present, so the first page view immediately carries user_id. That's why the practitioner guide recommends "put it in the config tag, and also in all event tags as a safety net." The config tag owns the first hit of a returning visit; the Event Settings Variable owns the current session.
Verification became clear too. In Preview, I stopped looking at the config tag (gtm.js). Instead, I opened an event that fired after login, the login tag, and there it was: user_id: '1', membership_status: 'member', sharp and clear. Changing where I was looking was all it took.
One small scar. Looking further at the real timeline, marketing_consent settled to true a beat after the login event. The user object arrived first; the consent value came slightly later. So the login event tag might still see consent as false. If you're gating anything on consent, don't rely on the dataLayer value at event time, have the server read the source directly just before sending. Fix the timing in one place, and it leaks somewhere else.
What I Thought Was a Side Note: user_id Is Not a User Property
The four properties were sorted out, but user_id kept behaving differently. It turned out user_id is not a user property, it's a reserved identifier in GA4. Not an attribute describing "what kind of person this is," but the identity backbone that determines "whose data this belongs to." So it goes in a different place: not in the user properties field, but in the dedicated user_id field of the Google tag.
Taking one more step in, the full picture of how GA4 identifies users came into view. GA4 doesn't determine identity from a single ID. As the Reporting identity docs explain, it uses a priority ladder: use the top; if it's not available, fall to the next.
Google signals used to sit in this ladder too, but Google has removed it from the identity use case. The ladder now has these three tiers. Choosing how far down to use it is the Reporting identity setting. There are three modes. Blended starts with User-ID, falls to device ID if absent, and falls to modeling if still absent. Observed drops modeling and uses only real identifiers. Device-based ignores User-ID entirely and uses only device ID. If member analysis matters, you need Blended or Observed, otherwise the user_id you worked hard to send gets discarded. You also need to enable User-ID reporting in GA4 Admin for cross-device identification to surface in reports.
What Is That UUID?
Our device_id value looks something like a48944fc-b19c-4557-8f2f-7fe0d0da4f36. This is a UUID (Universally Unique Identifier): a random 128-bit value designed so that no matter who generates it, when, or where, it essentially never collides with another. The number of possible values is astronomical, so the browser can generate and store one without checking a central server, with no worry of duplicates.
What matters is that this value contains no name, no phone number, it's a meaningless random number. So storing it as a user property is safe; it's not personally identifiable information (PII). But putting a real identifier there, a phone number, for example, is a different story. Even SHA-256 hashing a phone number does not make it "anonymous" under Korean law; it becomes pseudonymous data. The phone number space is small enough that anyone with a matching table can reverse it. So any value that actually points to a real person must be handled through a separate channel (e.g., server-side conversion sending), with separate consent and disclosure, not as a user property. The tracking number is fine; the recipient's name is not.
The Final Setup Trap: Sending Isn't Enough
Even if you send values correctly from GTM, they won't appear in GA4 reports. This is where first-timers always get stuck a second time. "Sending the value" and "making it visible in reports" are separate steps.
The GA4 official docs put it this way: "Adding a user property to your code allows a user-scoped custom dimension to unlock the ability to analyze that data." Without registering, data comes in but is invisible in reports. There's one more thing to pin down here. Custom dimensions have two scopes: event scope and user scope. Our four properties must be registered as "User" scope. Registering them as event scope by mistake means they only attach to that event and don't accumulate against the person. The limits differ too: 25 per property for user scope, 50 for event scope.
Here's the procedure. In GA4 Admin, go to Custom Definitions. Set scope to "User," type the exact same name as what GTM sends, character for character, and register all four. Names are case-sensitive. membership_status and Membership_status are treated as different properties. Expect 24 to 48 hours for data to reflect. While you're waiting, use GA4 DebugView to confirm user properties are being recorded in real time. No need to wait for reports.
That's the Plumbing. Now Let's Look at Revenue.
Setup is the means; analysis is the purpose. With user properties flowing in, you can now slice data by the person. The moment you cut by person instead of action, the gap hidden in the average surfaces, and where to put budget becomes visible.
There are three concrete paths from user properties to revenue. The numbers are illustrative; the structure is the point.
1. Shift Budget with Segment Comparison
Split conversion rates between members and non-members using membership_status. If the consultation conversion rate for members is five times higher than for non-members, you have a basis for spending more on "driving signups to turn non-members into members" rather than "ads to bring in new traffic." That judgment is impossible from a single average conversion rate. An average always hides half the truth.
2. Warm Up the Closest Converts with Audiences
People who are is_logged_in = true but haven't yet submitted a consultation request. They've shown interest but haven't converted. Build a GA4 Audience on that condition and export it to your ad platforms, and you're targeting the people closest to conversion first. They're warmer than cold new visitors and easier to nudge. It's the cheapest lever for pulling more conversions from the same ad spend.
3. Expand Reach with the Consented Audience
Filter to members with marketing_consent = true and use them as the target for consent-gated channels like SMS or push notifications. Holding consent in data lets you reach exactly "only the people you're allowed to reach." It structurally prevents accidentally sending to non-consenting users, while ensuring everyone you can reach is reached. Reducing risk while expanding reach at the same time is rare, this is one of those cases.
One step further. With a join key like device_id, you can reconnect conversions that happen offline days later, signups, activations, contracts, back to the same person, attributing them to the ad that drove them. User properties are one piece of that person-level linkage. That topic deserves its own post, so I'll just point to the door here.
One honest note: this is the mechanism by which the design can lift revenue, not results I've measured myself. The 8% and 1.5% conversion rates above are illustrative numbers meant to show the structure. Real figures come only after one or two days of data accumulating against the registered dimensions. What is clear, though, is that without the ability to slice by person, none of these decisions can even begin. User properties are the minimum part that makes that slicing possible.
Key Takeaways: User Property Setup and Usage Checklist
If you approach this with an e-commerce pixel mindset, you'll lose days on "why is the config tag empty?" For any business where members and non-members are mixed and logged-in vs. logged-out states matter, working with user properties comes down to a handful of principles.
- Don't panic at undefined in the config tag. The config tag fires at the earliest possible moment. Login values come after. Verify against events that fired after login, not the config tag.
- User properties attach to people, not tags. Once they arrive, they're stored on that person and follow them. No need to copy-paste them into every tag. The cumulative dataLayer makes that possible.
- Consolidate values in a single Event Settings Variable and share it. It's dynamically recalculated on every fire, so timing problems solve themselves. Putting values only in the static config tag is the original bug. Keep user_id in the config tag too as a safety net.
- Keep dataLayer variables at Version 2. Version 1 can't read accumulated values and will produce undefined again.
- user_id is an identifier, not a property. Put it in the dedicated field on the Google tag, and set Reporting identity to a User-ID mode (Blended or Observed) for it to be used in identification. Also enable User-ID reporting.
- Don't confuse your device_id with GA4's Device ID. Same name, entirely separate values. Ours is a random UUID, not PII. But don't put real identifiers like phone numbers in user properties. Even hashed, they're pseudonymous data.
- Setup is two steps. GTM sending + registering in GA4 as a "User"-scoped custom dimension. Step 1 alone means data accumulates invisibly. Names must match exactly (case-sensitive), limit is 25, verify with DebugView.
- Setup is the means; segmentation is the goal. Split conversion rates by membership, build audiences from logged-in non-converters, message only those who consented. Segmenting by person is what creates the basis for moving budget.
It started with a single undefined on one screen, but at the end it turned out to be a question that could only be solved by understanding how GA4 identifies and stores people. Not laying down all the tracking first and adding analysis later, but mapping out "what will I ultimately segment with this data" from the very beginning. User properties are the starting point for that segmentation, and the most foundational part of viewing revenue at the person level. One blank photo led the whole way there.
References
- GA4 User properties, Google official help. Basis for the definition of user properties (attributes describing people), reserved names, 25-per-property limit, and case-sensitivity of names.
- Create user-scoped custom dimensions in GA4, Google official help. Basis for collection (code/GTM) and registration (GA4 Admin) being separate steps, and registration being required for analysis in reports.
- Reuse event settings in GTM (Event Settings Variable), Google official help. Basis for consolidating user properties in one variable shared across all event tags, and values being dynamically recalculated on each send.
- GA4 Reporting identity, Google official help. Basis for the User-ID, Device ID (client_id), modeling priority ladder and the Blended/Observed/Device-based modes.
- Send User-ID to GA4, Google developer docs. Basis for user_id as a reserved identifier that links users across sessions and devices.
- Analytics Mania, GA4 User ID setup. Commentary on config tag values being static, handling values that arrive after login via event settings, and the double-placement standard in practice (secondary source, cross-checked against official docs above).
Starting from a GTM debug session where every user property showed undefined after login, this is a record of digging through GA4's scope model, cumulative dataLayer, identifiers, two-step setup, and analytics use. Two wrong turns and open questions (example numbers before real measurement, consent timing) left intact, so the next person doesn't stop at the same spot.
Found this useful? Share it
Latest posts
Kakao Share Thumbnail Not Showing? Here's the Fix (JavaScript SDK 2025)
Learn why Kakao Share thumbnails don't appear even with a valid imageUrl, and fix it using scrapImage to convert your URL into a KAGE CDN URL. Step-by-step guide.
How to Generate CRM User Manuals in 20 Minutes with Claude Code Sub-Agents
Learn how Claude Code sub-agents can analyze your CRM codebase and auto-generate user manuals and visual guidebooks in just 20 minutes. See the real-world results.
GEO Generative Engine Optimization: The Complete 2025 AI Marketing Strategy Guide
Learn what GEO (Generative Engine Optimization) is, how it differs from traditional SEO, and discover 5 proven AI marketing strategies to get cited by AI search engines in 2025.
Related projects
Get new posts by email
Insights on marketing, analytics, and dev, delivered to your inbox.