DEV Community

Kent Spencer
Kent Spencer

Posted on • Originally published at drewspen.blogspot.com on

GTM User Session Counter

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 dataLayer event 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)

  1. 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).
  2. Session guard is absent. The tag reads the _arusc cookie (Already Ran User Session Counter). It is absent, so sessionIsZero is true.
  3. Persistent counter is absent. The tag reads _i_vstcnt. It doesn't exist yet, so it is initialised to 0 and a persistent cookie is written with a 720-day max-age.
  4. Counter is incremented. 0 + 1 = 1. The counter cookie is rewritten with value 1 and a 720-day max-age.
  5. 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.
  6. Session guard is stamped. _arusc=1 is written as a session cookie (no max-age, expires when the browser closes). This prevents the counter from incrementing again during the same session.
  7. DOM Ready fires. The Extend Cookie Life Trigger activates because _arrewr is absent. The Cookie Rewriter tag refreshes the max-age of _i_vstcnt to another 720 days, then stamps _arrewr=1 for the rest of the session.

Session 2 (return visit, same browser)

  1. _arusc is absent (it was a session cookie — it expired with the browser close). The counter tag fires again.
  2. _i_vstcnt exists with value 1. The tag reads it, increments to 2, writes it back.
  3. dataLayer push: { event: '_i_vstcnt', userSessionCounter: 2 }.
  4. 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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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_vstcnt and 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 on userSessionCounter.

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

  1. Download the JSON from the Google Drive link.
  2. In GTM, go to Admin → Import Container.
  3. Upload user-session-counter-measurement-recipe.json.
  4. Choose Merge (not Overwrite) to preserve your existing tags.
  5. Update the Measurement Stream ID Development and Measurement Stream ID Production constant variables to your actual GA4 Stream IDs.
  6. Update the Root Domain variable if your site uses a non-standard subdomain structure.
  7. Preview in GTM Preview mode: open your site, check the Variables tab for User Session Counter Cookie Value, and verify it increments to 1 on first load.
  8. Close the browser tab entirely, reopen the site in Preview mode, and verify the counter increments to 2.
  9. 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_vstcnt server-side (it's a first-party cookie) to personalise page content before GTM even fires
  • Use userSessionCounter as 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.

⬇ Download GTM Container JSON

Top comments (0)