Persistent User Session Counter in Google Tag Manager — A Complete Recipe
Track how many times each visitor has started a new session — no server-side code, no CSP SHA-256 hashes required — and use the count to trigger conversion events, popups, and personalisation logic.
Published · Tags: GTM, Google Analytics 4, Cookies, Sessions, Custom Templates, JavaScript, dataLayer
Most analytics setups can tell you how many sessions a user has started in total across your property, but very few can answer the question at tag-fire time: "Is this visitor on their first session? Their third? Their tenth?" This recipe wires up a fully sandboxed GTM solution that increments a persistent counter cookie on every new session, pushes the count to the dataLayer as a named event, and extends the cookie's expiry on each return visit — all without a single line of Custom HTML or any SHA-256 CSP policy changes.
⬇ Download the GTM Container JSON from Google Drive
Why This Matters
Session count is one of the most actionable engagement signals you can capture. Knowing that a visitor is on their third session lets you:
- Fire a conversion-nudge popup only for returning visitors (not first-timers)
- Use the
dataLayerevent as a GA4 custom event trigger for micro-conversion reporting - Suppress first-session noise in paid-media conversion tracking
- Build GA4 audiences segmented by session depth (1 session, 2–5 sessions, 6+ sessions)
- Feed the count into other GTM tags as a variable — no extra API calls needed
No CSP changes required. Because this recipe uses only GTM's sandboxed Custom Template API (getCookieValues, setCookie, createQueue), there is no inline <script> block whose SHA-256 hash would need to be declared in your Content-Security-Policy header. Everything runs inside GTM's own sandboxed runtime.
What's Inside the Container
The exported container JSON (user-session-counter-measurement-recipe.json) bundles two tags, one trigger, and five custom templates across 25 variables:
| Asset Type | Name | Purpose |
|---|---|---|
| Tag (Custom Template) | User Session Counter | Reads the persistent counter cookie, increments it, writes it back, and pushes a dataLayer event — once per session via a session-scoped guard cookie |
| Tag (Custom Template) | Extend Cookie Life Tag | On DOM Ready, refreshes the persistent counter cookie's max-age so a returning visitor resets the expiry clock without changing the count |
| Tag (Google Tag) | Google Analytics Configuration | Standard GA4 config tag wired to environment-aware Stream IDs via a RegEx lookup variable |
| Trigger | Extend Cookie Life Trigger | DOM Ready trigger — fires only when the Extend Cookie Life session-guard cookie is absent or falsy, ensuring one rewrite per session |
| Custom Template | User Session Counter | The sandboxed JS engine for reading, incrementing, and persisting the counter |
| Custom Template | Cookie Rewriter | Sandboxed template that refreshes a list of persistent cookies to extend their max-age
|
| Custom Template | If Else If – Advanced Lookup Table | Environment-aware RegEx lookup for GA4 Stream ID routing (dev vs. prod) |
| Custom Template | Timestamp | Millisecond timestamp utility used by session/user ID variables |
| Custom Template | Get Root Domain | Resolves the root domain for cookie scoping across subdomains |
Architecture Overview
The flow across a visitor's two sessions illustrates how the guard-cookie + persistent-cookie pattern works:
Session 1 (first visit ever)
- GTM initialises. The User Session Counter tag fires (it has no limiting trigger — it fires on every container load by default, and its own session guard is the gatekeeper).
-
Session guard is absent. The tag reads the
_arusccookie (Already Ran User Session Counter). It is absent, sosessionIsZerois true. -
Persistent counter is absent. The tag reads
_i_vstcnt. It doesn't exist yet, so it is initialised to0and a persistent cookie is written with a 720-daymax-age. -
Counter is incremented.
0 + 1 = 1. The counter cookie is rewritten with value1and a 720-daymax-age. -
dataLayer event is pushed.
{ event: '_i_vstcnt', userSessionCounter: 1 }fires. Any downstream tags listening for this event name (e.g. GA4 event tags, popup triggers) activate now. -
Session guard is stamped.
_arusc=1is written as a session cookie (nomax-age, expires when the browser closes). This prevents the counter from incrementing again during the same session. -
DOM Ready fires. The Extend Cookie Life Trigger activates because
_arrewris absent. The Cookie Rewriter tag refreshes themax-ageof_i_vstcntto another 720 days, then stamps_arrewr=1for the rest of the session.
Session 2 (return visit, same browser)
-
_aruscis absent (it was a session cookie — it expired with the browser close). The counter tag fires again. -
_i_vstcntexists with value1. The tag reads it, increments to2, writes it back. -
dataLayer push:
{ event: '_i_vstcnt', userSessionCounter: 2 }. - Cookie life is extended again on DOM Ready.
The User Session Counter Template — Sandboxed JS
This is the heart of the recipe. It runs entirely inside GTM's sandboxed JavaScript environment — no document.cookie, no window access — using only approved GTM APIs. This is why no CSP SHA-256 hash is needed : the sandbox, not a Custom HTML tag, is doing all the cookie work.
const log = require('logToConsole');
const getCookieValues = require('getCookieValues');
const setCookie = require('setCookie');
const createQueue = require('createQueue');
const dataLayerPush = createQueue('dataLayer');
const alreadyRanUserSessionCounter = data.alreadyRanUserSessionCounterName || '_arusc';
const cookieName = data.userSessionCounter || 'usrSsnCtr';
const sessionValues = getCookieValues(alreadyRanUserSessionCounter);
const sessionRaw = sessionValues && sessionValues.length > 0 ? sessionValues[0] : null;
const sessionExists = sessionRaw !== null;
const sessionIsZero = !sessionExists || (sessionRaw - 0) === 0;
if (!sessionExists) {
setCookie(alreadyRanUserSessionCounter, '0', { domain: data.rootDomain, path: '/' });
}
if (sessionIsZero) {
const persistValues = getCookieValues(cookieName);
const persistRaw = persistValues && persistValues.length > 0 ? persistValues[0] : null;
const persistExists = persistRaw !== null;
let currentCount = 0;
if (!persistExists) {
setCookie(cookieName, '0', {
domain: data.rootDomain,
path: '/',
'max-age': 60 * 60 * 24 * (data.persistentCookieExpires - 0)
});
currentCount = 0;
} else {
currentCount = (persistRaw - 0);
}
const newCount = currentCount + 1;
setCookie(cookieName, newCount.toString(), {
domain: data.rootDomain,
path: '/',
'max-age': 60 * 60 * 24 * (data.persistentCookieExpires - 0)
});
dataLayerPush({
event: cookieName,
userSessionCounter: newCount
});
setCookie(alreadyRanUserSessionCounter, '1', { domain: data.rootDomain, path: '/' });
}
data.gtmOnSuccess();
Dual-purpose dataLayer push: The event name is set to the cookie name itself (_i_vstcnt by default). This means the same event can be listened to by a Custom Event trigger in GTM, enabling the session count to serve as a trigger for GA4 conversion events, timed popup dialogs, chat widget activations, or any other tag that should fire at specific session-depth thresholds.
The Cookie Rewriter Template — Extending Cookie Life
A 720-day max-age is written when the counter cookie is first created, and again each time it is incremented — but what about sessions where the user returns after a long gap and the cookie would otherwise have been expiring soon? The Cookie Rewriter (Extend Cookie Life Tag) handles this: on every session's DOM Ready, it reads the current value and rewrites the cookie with a fresh 720-day expiry.
var getCookieValues = require('getCookieValues');
var setCookie = require('setCookie');
var makeString = require('makeString');
var log = require('logToConsole');
var SESSION_GUARD = data.SESSION_GUARD || '_arrewr';
// Run at most once per session
var guardValues = getCookieValues(SESSION_GUARD);
if (guardValues && guardValues.length > 0 && guardValues[0] === '1') {
data.gtmOnSuccess();
return;
}
var rootDomain = data.rootDomain;
var durationDays = data.durationDays;
var cookieList = data.cookieList;
var maxAgeSeconds = makeString(durationDays * 24 * 60 * 60);
if (cookieList && cookieList.length > 0) {
for (var i = 0; i < cookieList.length; i++) {
var name = cookieList[i].cookieName;
if (!name) continue;
var values = getCookieValues(name);
if (values && values.length > 0 && values[0] !== '') {
setCookie(name, values[0], {
domain: rootDomain,
path: '/',
'max-age': maxAgeSeconds,
samesite: 'Lax',
secure: true
});
}
}
}
// Stamp the session guard so this doesn't run again this session
setCookie(SESSION_GUARD, '1', { domain: rootDomain, path: '/' });
data.gtmOnSuccess();
Cookies Written by This Recipe
| Cookie Name | Type | Purpose | Default Expiry |
|---|---|---|---|
_i_vstcnt |
Persistent | The actual session counter value (integer, increments each session) | 720 days (refreshed each visit) |
_arusc |
Session | Guard cookie — prevents multiple increments within a single session | Browser close |
_arrewr |
Session | Guard cookie — prevents the Cookie Rewriter from running more than once per session | Browser close |
Two types of guard cookies, two jobs: The session-scoped _arusc ensures the counter increments exactly once per browser session. The separate _arrewr guard ensures the Cookie Rewriter runs exactly once per session. Because both guards are session cookies (no max-age), they vanish naturally when the browser is closed — no cleanup needed.
Variables
Key Constants
| Variable Name | Type | Value |
|---|---|---|
| User Session Counter Cookie Name | Constant | _i_vstcnt |
| Extend Cookie Life Already Ran Cookie Name | Constant | _arrewr |
| Measurement Stream ID Development | Constant |
G-ZZZZZZZZ (replace with yours) |
| Measurement Stream ID Production | Constant |
G-AAAAAAAA (replace with yours) |
Key Cookie-Reading Variables
| Variable Name | Type | Reads Cookie |
|---|---|---|
| User Session Counter Cookie Value | 1st-party cookie |
_i_vstcnt — the count, readable by any other GTM tag |
| Extend Cookie Life Already Ran Cookie Value | 1st-party cookie |
_arrewr — used by the Extend Cookie Life Trigger condition |
Triggers
User Session Counter Tag
The User Session Counter tag fires on All Pages (or any early-firing trigger you choose). Its own internal session guard (_arusc) is the gatekeeper — the guard logic is inside the template rather than in an external trigger condition. This keeps the tag self-contained and avoids the need for a separate "has not already ran" initialization trigger.
Extend Cookie Life Trigger
Fires on DOM Ready , with one filter condition: {{Extend Cookie Life Already Ran Cookie Value}} matches the regex ^(undefined|null|0|false|NaN|)$. In plain English: fire only when the _arrewr cookie is absent or falsy. This ensures the Cookie Rewriter runs exactly once per session.
Dual-Purpose dataLayer Event
The push made by the User Session Counter template serves two roles simultaneously:
-
Cookie persistence: The counter value is stored in
_i_vstcntand survives across pages and sessions for up to 720 days. -
Custom event trigger: The
dataLayer.push({ event: '_i_vstcnt', userSessionCounter: N })can be listened to by a GTM Custom Event trigger. Any tag that should react to session depth — a GA4 conversion event, a popup timer, a live-chat invite — can be wired to this trigger with a variable condition onuserSessionCounter.
Example use case: Create a Custom Event trigger matching _i_vstcnt, add a trigger condition where {{User Session Counter Cookie Value}} equals 3, and wire it to a "show free trial popup" tag. The popup fires on the visitor's third session — and never on the first or second.
How to Import
- Download the JSON from the Google Drive link.
- In GTM, go to Admin → Import Container.
- Upload
user-session-counter-measurement-recipe.json. - Choose Merge (not Overwrite) to preserve your existing tags.
- Update the Measurement Stream ID Development and Measurement Stream ID Production constant variables to your actual GA4 Stream IDs.
- Update the Root Domain variable if your site uses a non-standard subdomain structure.
- Preview in GTM Preview mode: open your site, check the Variables tab for
User Session Counter Cookie Value, and verify it increments to1on first load. - Close the browser tab entirely, reopen the site in Preview mode, and verify the counter increments to
2. - Publish when satisfied.
Consent gating: The persistent _i_vstcnt counter cookie is a functional/analytics cookie. If your site uses a consent management platform, gate the User Session Counter tag on analytics_storage or functionality_storage consent as appropriate for your jurisdiction.
What You Can Do With It
- Suppress first-session users from conversion funnels to reduce noise in paid-media ROAS reporting
- Build GA4 audiences segmented by session depth: New (1 session), Returning (2–5), Loyal (6+)
- Trigger a discount popup only on the third session, avoiding offer fatigue for first-time visitors
- Read
_i_vstcntserver-side (it's a first-party cookie) to personalise page content before GTM even fires - Use
userSessionCounteras a GA4 event parameter to segment event reports by engagement depth - Fire a live-chat proactive invite only for visitors on session 4 or higher
The full source — including the container JSON, all custom template code, and a breakdown of every variable — is available on GitHub. Questions or improvements? Open an issue.
Top comments (0)