Session Duration Timer in GTM

Session Duration Timer in Google Tag Manager — A Complete Recipe Using Sandboxed JS

Measure seconds spent on your site per session — or across all sessions — with no CSP SHA-256 hashes, no external libraries, and a single importable container JSON.

Published · Tags: GTM, Google Analytics 4, Session Duration, Cookies, dataLayer, Custom Template, JavaScript

Most GTM engagement-time solutions either lean on GA4's built-in engagement time (which you can't trigger mid-session) or inject inline <script> tags that break under a strict Content Security Policy. This recipe solves both problems. It uses a GTM Sandboxed JavaScript custom template — the Session or User Seconds Duration tag — to run a per-second callLater loop that writes the running count to a first-party cookie and pushes a named dataLayer event at every tick. The result is importable in one JSON file, zero SHA-256 exemptions required.

⬇ Download the GTM Container JSON from Google Drive

What's Inside the Container

The exported GTM recipe (session-duration-timer-measurement-recipe.json) contains the following assets:

Asset TypeNamePurpose
Tag (Custom Template)Session DurationSandboxed JS timer loop — increments a counter each second, writes to cookie, pushes durationSeconds to the dataLayer
Tag (GA4 Event)Session Duration Send EventFires a dynamically named GA4 event (session_duration_Nsec) at configured second milestones
Tag (Google Tag)Google Analytics ConfigurationStandard GA4 config tag routed via environment-aware stream ID lookup
TriggerSession Duration EventCustom event trigger — fires when durationSeconds matches the regex ^(6|11)$ and the event name matches the cookie name
Custom Template (Tag)Session or User Seconds DurationThe engine: GTM sandboxed JS template by drewspen — no document.cookie, no inline script, no CSP issues
Custom Template (Variable)If Else If – Advanced Lookup TableEnvironment-aware regex lookup for GA4 Stream ID routing (by Sublimetrix)
Custom Template (Variable)TimestampMillisecond timestamp provider (by luratic)
Custom Template (Variable)Get Root DomainExtracts registrable root domain for cookie scoping (by mbaersch)
Variables (many)Session Duration Cookie Name, Session Duration dataLayer, is Session Duration Event, Browser Session/Client ID, Timestamps…Supporting variable layer for stream routing, session identity, and event detection

Architecture Overview

Here is how the timer flows from page load to GA4:

  1. Page loads. The Session Duration tag fires on GTM container load (All Pages trigger or your chosen trigger).
  2. Cookie is read. The template calls GTM's sandboxed getCookieValues(cookieName). If a count already exists in the _i_vstdur cookie — including from a previous page in the same session — the counter resumes from that number rather than zero.
  3. Timer loop starts. callLater(tick) schedules a 1-second recursive loop using GTM's sandboxed callLater API. Each tick: increments the counter, writes it back to the cookie with setCookie(), and pushes a dataLayer event.
  4. dataLayer is updated every second. Each push follows the pattern: { event: '_i_vstdur', durationSeconds: N }. The event name is the cookie name itself, enabling the Simple Value Map lookup variable is Session Duration Event to confirm the event identity.
  5. Trigger fires at milestones. The Session Duration Event custom event trigger checks two conditions: durationSeconds matches ^(6|11)$ AND is Session Duration Event equals true. At second 6 and second 11 (configurable), the trigger fires.
  6. GA4 event is sent. The Session Duration Send Event tag fires a dynamically named GA4 event: session_duration_6sec or session_duration_11sec. A matching event parameter (session_duration_6sec_count: 1) is sent alongside it, making these countable as conversion metrics in GA4.
  7. Loop halts at maxSeconds. The template's if (counter >= maxSecondsNum) return; guard ensures the loop stops cleanly at your configured ceiling (11 seconds by default), preventing runaway calls.

The Core Engine — Session or User Seconds Duration (Custom Template)

This is the heart of the recipe. Rather than injecting a raw <script> tag — which would require a CSP SHA-256 hash for every GTM publish — this is built as a GTM sandboxed JavaScript custom template tag. It uses only GTM's permitted sandboxed APIs: no document.cookie, no setInterval, no window access. This is the key reason the solution is CSP-friendly out of the box.

const log             = require('logToConsole');
const getCookieValues = require('getCookieValues');
const setCookie       = require('setCookie');
const createQueue     = require('createQueue');
const dataLayerPush   = createQueue('dataLayer');
const callLater       = require('callLater');
const getTimestamp    = require('getTimestamp');

const cookieName    = data.durationCookie || 'duration';
const maxSecondsNum = (data.maxSeconds || 11) - 0;

const cookieOptions = data.cookieDuration === 'persistentCookie'
  ? { domain: data.rootDomain, path: '/', 'max-age': 60 * 60 * 24 * (data.persistentCookieExpires - 0) }
  : { domain: data.rootDomain, path: '/' };

// Resume from existing cookie value if present
const existingValues = getCookieValues(cookieName);
const existing       = (existingValues && existingValues.length > 0) ? existingValues[0] : null;
const existingNum    = existing - 0;
let counter = (existing !== null && existing !== '' && existingNum === existingNum)
              ? existingNum : 0;

if (existing === null || existing === '') {
  setCookie(cookieName, '0', cookieOptions);
}

const intervalMs = 1000;
let lastTick = getTimestamp();

function tick() {
  if (counter >= maxSecondsNum) return;
  const now = getTimestamp();
  if ((now - lastTick) >= intervalMs) {
    lastTick = now;
    counter  = counter - (-1);            // avoids unary + operator (sandboxed constraint)
    setCookie(cookieName, counter + '', cookieOptions);
    dataLayerPush({
      event:           cookieName,        // e.g. '_i_vstdur'
      durationSeconds: counter
    });
    if (counter < maxSecondsNum) { callLater(tick); }
  } else { callLater(tick); }
}

callLater(tick);
data.gtmOnSuccess();
Why counter - (-1) instead of counter++? GTM's sandboxed JS environment forbids certain operators. Subtracting negative one is a safe equivalent to incrementing that passes the sandboxed linter.
No CSP SHA-256 required. Because this logic lives inside a sandboxed custom template (not a Custom HTML tag), GTM loads it as part of the container's own script bundle. There is no separate inline <script> block that a Content Security Policy would need to whitelist with a hash. Publish freely without touching your CSP headers.

Template Parameters

ParameterTypeDescription
rootDomainSelect (macro)Cookie domain scope. Defaults to {{Root Domain}}. Example: .example.com
cookieDurationRadiosessionCookie — clears when browser closes. persistentCookie — survives across sessions.
persistentCookieExpiresNumberDays until expiry when using persistentCookie. Defaults to 365. Unused for session cookies.
durationCookieTextCookie name. Populated from the Session Duration Cookie Name constant: _i_vstdur
maxSecondsNumberCeiling at which the loop halts. Set to 11 in this recipe (configurable to any value).

Dual Output: Cookie + dataLayer Event

Every second the template does two things simultaneously, and the combination unlocks several use cases that neither output enables alone:

1. Cookie — _i_vstdur

The running second count is written to a first-party cookie on every tick. Because cookies are domain-scoped and persist across page navigations, the counter carries across pages within the same session. A visitor who spends 4 seconds on Page A and then navigates to Page B will resume at 4 on Page B — the total session engagement time accumulates naturally.

Switching cookieDuration from sessionCookie to persistentCookie extends this behaviour across sessions entirely. A returning visitor's cumulative seconds on site will pick up from where they left off — even days later — enabling lifetime engagement measurement without a login or backend.

2. dataLayer Event — _i_vstdur with durationSeconds: N

The dataLayerPush call each second creates a GTM custom event. This event is independently valuable in several ways:

  • Conversion trigger: The included Session Duration Event trigger fires a GA4 event at second 6 and second 11. In GA4, mark session_duration_6sec as a conversion — now your GA4 reports show how many users reached meaningful engagement thresholds.
  • Popup / dialog trigger: Any other GTM tag can listen for the same custom event trigger. Fire a newsletter signup popup at second 30, an exit-intent offer at second 60, or a user satisfaction survey at second 120 — all configurable from the trigger's regex without touching a line of JavaScript.
  • Personalization signal: Read durationSeconds from the dataLayer in other tags or variables to conditionally show content to engaged vs. bounced visitors in real time.
The trigger regex is the configuration knob. The current trigger fires at ^(6|11)$. To fire at 30, 60, and 120 seconds instead, change the regex to ^(30|60|120)$ — no code change, no republish of the template, just a trigger edit and a container publish.

The GA4 Event — Session Duration Send Event

The GA4 event tag produces dynamically named events using GTM variable interpolation:

GA4 Event PropertyValueExample at 6 sec
Event Namesession_duration_{{Session Duration dataLayer}}secsession_duration_6sec
Event Parameter (name)session_duration_{{Session Duration dataLayer}}sec_countsession_duration_6sec_count
Event Parameter (value)11
Measurement ID Override{{Measurement Stream RegEx Lookup}}Routes to dev or prod stream automatically

Sending a parameter with a constant value of 1 alongside each event is intentional. In GA4 Explore reports, summing this parameter gives you the total count of users (or sessions) who reached each second milestone — a simple, reliable engagement funnel without requiring calculated metrics.

Trigger Detail — Session Duration Event

This custom event trigger uses two conditions that must both pass:

ConditionVariableValue
Seconds match milestone{{Session Duration dataLayer}} (DataLayer variable reading durationSeconds)Matches regex ^(6|11)$
Event identity confirmed{{is Session Duration Event}} (Simple Value Map: event name → true)Equals true

The second condition prevents false positives if another dataLayer event happens to contain a durationSeconds key. Only events whose name matches the Session Duration Cookie Name constant (_i_vstdur) will resolve the Simple Value Map to true.

Session vs. Persistent Cookie — Cross-Session Measurement

The template ships with cookieDuration set to sessionCookie, which clears when the browser tab or window closes. This measures per-session engagement time — the most common use case. To measure lifetime user engagement across sessions, switch the radio to persistentCookie and set persistentCookieExpires to your desired retention window (365 days is the default). The logic is unchanged; only the cookie's max-age attribute is affected.

ModeCookie TypeWhat It MeasuresUse Case
DefaultSession cookie (no expiry)Seconds per browser sessionStandard engagement depth, bounce quality, conversion timing
PersistentPersistent cookie (N days)Cumulative seconds across all sessionsLifetime engagement, loyalty scoring, subscriber depth measurement

Environment-Aware Stream Routing

The container includes a dual-layer regex lookup system so the same container file works across dev and production without manual edits:

  1. URL pattern check: Measurement Stream RegEx Lookup scans the Page URL for patterns like dev., staging., uat., local., and the GTM preview URL (gtm-msr.appspot.com). Matches route to the development stream ID.
  2. GTM environment name fallback: Environment Stream ID checks the GTM environment name for a ^liv.* pattern (Live) or pre-release environment names as a secondary signal.
  3. Default: If neither pattern matches, the production stream ID is used.

Update Measurement Stream ID Production and Measurement Stream ID Development with your actual GA4 measurement IDs. The regex patterns need no changes for most standard setups.

How to Import

  1. Download the JSON from the Google Drive link.
  2. In GTM, go to Admin → Import Container.
  3. Upload session-duration-timer-measurement-recipe.json.
  4. Choose Merge (not Overwrite) to preserve your existing tags.
  5. Update Measurement Stream ID Production and Measurement Stream ID Development constants to your actual GA4 stream IDs.
  6. Adjust maxSeconds and the trigger regex ^(6|11)$ to your desired milestone seconds.
  7. If you want cross-session measurement, switch cookieDuration from sessionCookie to persistentCookie in the Session Duration tag parameters.
  8. Preview in GTM Preview mode, confirm cookie writes and dataLayer events at each second, then publish.
Consent: The session duration cookie and GA4 event should be gated on analytics_storage consent in your GTM consent settings if your site is subject to GDPR or similar regulation. The tag will not fire until consent is granted.

What You Can Do With It

  • Mark session_duration_30sec as a GA4 conversion to track meaningful engagement as a conversion metric in campaign reports
  • Use the custom event trigger at any second to fire a popup, chat widget, or modal without any additional JavaScript
  • Read _i_vstdur in other GTM tags or personalization scripts to segment engaged vs. disengaged visitors in real time on the current page
  • Switch to persistent cookies to build a lifetime engagement score for registered users or newsletter subscribers
  • Combine with the geolocation recipe to correlate session depth with geography — do users from certain cities engage longer?
  • Use the durationSeconds dataLayer variable in custom dimensions or BigQuery exports for cohort analysis in Looker Studio

The full source — including the container JSON, all custom template code, variable definitions, and trigger configuration — is available on GitHub. Questions or improvements? Open an issue or submit a pull request.

⬇ Download GTM Container JSON

Comments

Popular posts from this blog

Site Landing & Site Referrer Preservation

GTM Browser Viewport Measurement

UTM & URL Query String 2 Cookies