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.
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.
What's Inside the Container
The exported GTM recipe (session-duration-timer-measurement-recipe.json) contains the following assets:
| Asset Type | Name | Purpose |
|---|---|---|
| Tag (Custom Template) | Session Duration | Sandboxed JS timer loop — increments a counter each second, writes to cookie, pushes durationSeconds to the dataLayer |
| Tag (GA4 Event) | Session Duration Send Event | Fires a dynamically named GA4 event (session_duration_Nsec) at configured second milestones |
| Tag (Google Tag) | Google Analytics Configuration | Standard GA4 config tag routed via environment-aware stream ID lookup |
| Trigger | Session Duration Event | Custom 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 Duration | The engine: GTM sandboxed JS template by drewspen — no document.cookie, no inline script, no CSP issues |
| Custom Template (Variable) | If Else If – Advanced Lookup Table | Environment-aware regex lookup for GA4 Stream ID routing (by Sublimetrix) |
| Custom Template (Variable) | Timestamp | Millisecond timestamp provider (by luratic) |
| Custom Template (Variable) | Get Root Domain | Extracts 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:
- Page loads. The Session Duration tag fires on GTM container load (All Pages trigger or your chosen trigger).
- Cookie is read. The template calls GTM's sandboxed
getCookieValues(cookieName). If a count already exists in the_i_vstdurcookie — including from a previous page in the same session — the counter resumes from that number rather than zero. - Timer loop starts.
callLater(tick)schedules a 1-second recursive loop using GTM's sandboxedcallLaterAPI. Each tick: increments the counter, writes it back to the cookie withsetCookie(), and pushes a dataLayer event. - 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. - Trigger fires at milestones. The Session Duration Event custom event trigger checks two conditions:
durationSecondsmatches^(6|11)$ANDis Session Duration Eventequalstrue. At second 6 and second 11 (configurable), the trigger fires. - GA4 event is sent. The Session Duration Send Event tag fires a dynamically named GA4 event:
session_duration_6secorsession_duration_11sec. A matching event parameter (session_duration_6sec_count: 1) is sent alongside it, making these countable as conversion metrics in GA4. - 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();
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.
<script> block that a Content Security Policy would need to whitelist with a hash. Publish freely without touching your CSP headers.
Template Parameters
| Parameter | Type | Description |
|---|---|---|
rootDomain | Select (macro) | Cookie domain scope. Defaults to {{Root Domain}}. Example: .example.com |
cookieDuration | Radio | sessionCookie — clears when browser closes. persistentCookie — survives across sessions. |
persistentCookieExpires | Number | Days until expiry when using persistentCookie. Defaults to 365. Unused for session cookies. |
durationCookie | Text | Cookie name. Populated from the Session Duration Cookie Name constant: _i_vstdur |
maxSeconds | Number | Ceiling 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_6secas 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
durationSecondsfrom the dataLayer in other tags or variables to conditionally show content to engaged vs. bounced visitors in real time.
^(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 Property | Value | Example at 6 sec |
|---|---|---|
| Event Name | session_duration_{{Session Duration dataLayer}}sec | session_duration_6sec |
| Event Parameter (name) | session_duration_{{Session Duration dataLayer}}sec_count | session_duration_6sec_count |
| Event Parameter (value) | 1 | 1 |
| 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:
| Condition | Variable | Value |
|---|---|---|
| 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.
| Mode | Cookie Type | What It Measures | Use Case |
|---|---|---|---|
| Default | Session cookie (no expiry) | Seconds per browser session | Standard engagement depth, bounce quality, conversion timing |
| Persistent | Persistent cookie (N days) | Cumulative seconds across all sessions | Lifetime 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:
- 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. - 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. - 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
- Download the JSON from the Google Drive link.
- In GTM, go to Admin → Import Container.
- Upload
session-duration-timer-measurement-recipe.json. - Choose Merge (not Overwrite) to preserve your existing tags.
- Update Measurement Stream ID Production and Measurement Stream ID Development constants to your actual GA4 stream IDs.
- Adjust
maxSecondsand the trigger regex^(6|11)$to your desired milestone seconds. - If you want cross-session measurement, switch
cookieDurationfromsessionCookietopersistentCookiein the Session Duration tag parameters. - Preview in GTM Preview mode, confirm cookie writes and dataLayer events at each second, then publish.
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_30secas 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_vstdurin 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
durationSecondsdataLayer 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
Post a Comment