The web has a short memory. Every page load starts from nothing: the dataLayer is empty, every GTM variable is undefined, and whatever you captured a click ago is gone. This is fine until it isn’t. A user lands on a campaign page carrying a discount code in the URL, browses three pages, and arrives at checkout where the code you so carefully captured has evaporated. The dataLayer lives on the window object, and a new page load builds a new window. Nothing carries over by default.
To make data survive the journey you have to write it somewhere navigation does not erase, and the browser offers two client-side options: cookies, the twenty-year-old standard, and the Web Storage API (localStorage and sessionStorage), the cleaner modern alternative. This article covers both, with the GTM patterns that turn them into reliable cross-page persistence.
Why the dataLayer Forgets
It is worth seeing the problem concretely. On a landing page, the site pushes utm_campaign and a discount value, and GTM reads them happily. The user clicks through to a product page, and that same read now returns undefined for both, because the product page started with a fresh, empty dataLayer. The push from page one never happened in page two’s world. Every persistence technique below is a way of stashing a value outside the window so the next page can pick it back up.
Method 1: Browser Cookies
A cookie is a small string the browser stores and attaches to every HTTP request for the matching domain. That last detail is the cookie’s defining feature: the server sees it. Cookies cap out around 4 KB each, can be scoped by domain and path, and can either expire when the tab closes (a session cookie) or persist to an explicit date. They work in every browser and have for two decades.
The native API is famously awkward. Writing looks like setting a property, while reading hands you every cookie concatenated into one string that you then have to parse:
document.cookie = 'discount=SAVE20; expires=Fri, 31 Dec 2026 23:59:59 GMT; path=/';console.log(document.cookie);// → "discount=SAVE20; utm_campaign=spring_sale; _ga=GA1.2.123456789"
Despite appearances, assigning to document.cookie adds or updates one cookie rather than overwriting them all. The expiresattribute sets the expiry as a UTC string, and omitting it creates a session cookie. The path=/ makes the cookie available site-wide; leave it off and the cookie is scoped to the current directory only, which is a subtle source of “why can’t checkout see this cookie” bugs.
Because hand-writing those strings is error-prone, the established pattern (popularized in Simo Ahava’s writing on the subject) is a reusable Custom JavaScript Variable that returns a setter function:
function() { return function(name, value, ms, path, domain) { if (!name || !value) { return; } var cpath = path ? '; path=' + path : ''; var cdomain = domain ? '; domain=' + domain : ''; var expires = ''; if (ms) { var d = new Date(); d.setTime(d.getTime() + ms); expires = '; expires=' + d.toUTCString(); } document.cookie = name + '=' + encodeURIComponent(value) + expires + cpath + cdomain; };}
The variable returns a function you call from tags. The neat part is the ms parameter: you express expiry as milliseconds from now rather than wrestling with UTC date strings, and the function converts that offset to an absolute timestamp internally. The guard against a blank name or value stops you from writing junk cookies, and encodeURIComponent keeps special characters from corrupting the string.
Calling it from a Custom HTML tag reads cleanly:
<script>// 30 days = 30 * 24 * 60 * 60 * 1000 = 2592000000 ms{{JS - setCookie}}('discount', 'SAVE20', 2592000000, '/', 'yoursite.com');// Session cookie: omit ms, deleted when the browser closes{{JS - setCookie}}('referrer_page', {{Page Path}}, undefined, '/');</script>
Reading is the easy direction, because GTM has a built-in 1st Party Cookie variable. You point it at a cookie name like discount, name the variable {{Cookie - Discount Code}}, and it returns the value on every page until the cookie expires. No parsing required on the read side.
Method 2: The Web Storage API
Web Storage offers two key-value stores with a far nicer interface, both holding around 5 MB per origin, both invisible to the server. The difference between them is lifetime and scope. localStorage never expires on its own and is shared across every tab on the same origin, which suits data that must survive across sessions: user preferences, a CRM segment, first-touch attribution. sessionStorage is cleared when the tab closes and is isolated to the tab that created it, which suits one-session data: multi-step form progress, the current journey state, temporary edits. Cookies remain the right choice only when the server needs to read the value.
The interface is a plain key-value API:
localStorage.setItem('utm_campaign', 'spring_sale');var campaign = localStorage.getItem('utm_campaign'); // → 'spring_sale'var missing = localStorage.getItem('nonexistent'); // → nulllocalStorage.removeItem('utm_campaign');
One detail matters more than it looks: a missing key returns null, not undefined. Because 'false' and '0' are truthy strings once stored, you should check !== null rather than relying on plain truthiness, or you will misread legitimately stored values.
Web Storage only holds strings, so objects and arrays need JSON serialization on the way in and parsing on the way out:
var cartData = { items: ['SKU-00441', 'SKU-00389'], coupon: 'SAVE20', currency: 'GBP' };localStorage.setItem('cart_snapshot', JSON.stringify(cartData));var raw = localStorage.getItem('cart_snapshot');var cart = raw ? JSON.parse(raw) : null;
The raw ? JSON.parse(raw) : null guard is not optional decoration; calling JSON.parse(null) on a key that was never set throws, so you always check before parsing.
Web Storage is also not guaranteed to exist. Private browsing in some older Safari versions and certain in-app browsers block it, so a feature-detection guard with a cookie fallback is the robust pattern:
if (window['Storage']) { localStorage.setItem('utm_campaign', 'spring_sale');} else { {{JS - setCookie}}('utm_campaign', 'spring_sale', 604800000, '/');}
Reading Web Storage back into GTM needs a Custom JavaScript Variable, since there is no built-in reader. Wrap it in try/catch because some restricted environments throw a SecurityError on access rather than merely returning nothing:
function() { try { return localStorage.getItem('utm_campaign') || undefined; } catch(e) { return undefined; }}
Converting null to undefined with the || undefined keeps the variable consistent with how GTM treats missing values everywhere else.
Patterns That Earn Their Keep
First-touch attribution is the headline use case. You want the eventual conversion to credit the campaign that originally brought the user in, not whatever they last clicked. A tag on All Pages at DOM Ready checks each UTM parameter and stores it only if it is present in the URL and not already saved:
<script>(function() { var params = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']; var thirtyDays = 30 * 24 * 60 * 60 * 1000; params.forEach(function(param) { var urlValue = {{JS - getQueryParam}}(param); var storedValue = localStorage.getItem('ft_' + param); if (urlValue && !storedValue) { localStorage.setItem('ft_' + param, urlValue); {{JS - setCookie}}('ft_' + param, urlValue, thirtyDays, '/'); } });})();</script>
The ft_ prefix marks these as first-touch values, the if (urlValue && !storedValue) ensures a later visit with different UTMs never overwrites the original, and the parallel cookie write gives server-side rendering access to the same values. You then read each back with a small Custom JavaScript Variable and send them as GA4 event parameters like first_touch_campaign.
Persisting CRM data follows the same shape. When a user logs in, a tag firing on a userIdentified event serializes their segment, tier, and industry into sessionStorage, falling back to a cookie if storage is blocked. A second tag on All Pages at DOM Ready reads that object back and re-pushes it to the dataLayer as a crmDataRestored event, so every subsequent page has the user’s context available without a fresh server render. The whole chain is wrapped in try/catch so a storage error in a restricted browser fails silently rather than breaking the page.
Multi-step form progress stores the current step in sessionStorage keyed by form id, so a returning user can be detected mid-form. Referrer capture solves a sharp-edged problem: the external referrer is only visible on the landing page, because on every later page document.referrer shows the internal previous page instead. Capturing it once, gated on the referrer being external and not already stored, preserves the true original source for the whole session:
<script>(function() { try { var existing = localStorage.getItem('original_referrer'); if (!existing && document.referrer && document.referrer.indexOf('yoursite.com') === -1) { localStorage.setItem('original_referrer', document.referrer); localStorage.setItem('original_landing', window.location.pathname); } } catch(e) {}})();</script>
Choosing the Right Store
The decision flows from a few questions. Does the server need to read the value? If yes, it has to be a cookie, because Web Storage never leaves the browser. Must the data survive the browser closing? If yes, use localStorage or a cookie with an explicit expiry; if it only needs to last the current visit, sessionStorage is the natural fit. Does the data belong to one tab or all of them? One tab points to sessionStorage, all tabs to localStorage or a cookie. Is the value larger than 4 KB? Then Web Storage’s roomier 5 MB is your only client-side option. And is it authentication-related or sent on every request? Then a server-set cookie with HttpOnly and Secure flags is correct, and Web Storage is wrong.
The trade-offs line up cleanly. Cookies are the only mechanism the server sees, the only one with a built-in GTM reader, and the only one that spans subdomains (with domain=.yoursite.com set), but they are capped at 4 KB. Web Storage gives you far more room and a cleaner API, with localStorage persisting indefinitely across tabs and sessionStorage living and dying with a single tab, but neither reaches the server or crosses origins.
Consent Comes First
None of this should run before the user has consented. Writing UTM values or any identifier to storage ahead of consent is an ePrivacy and GDPR problem, not a nicety. The clean enforcement in GTM is to add a consent condition to every storage-writing tag, firing only when analytics_storage equals granted under Consent Mode, rather than scattering manual cookie checks through your tag code. This is the same discipline that runs through proper consent implementation QA: the value should be structurally incapable of being written before permission exists, not merely discouraged.
The Mistakes to Avoid
A handful of errors account for most persistence bugs. Never call localStorage.clear() inside a GTM tag; it wipes every key in the origin, including ones set by your CMS, other tools, and other teams, so remove specific keys with .removeItem()instead. Never store an object without JSON.stringify, or you save the useless string "[object Object]". Always wrap JSON.parsein try/catch, because a corrupted or partial value throws. Check !== null rather than plain truthiness when reading, so stored values like '0' and 'false' are not silently discarded. Always set path=/ on cookies meant to be site-wide. And do not confuse sessionStorage‘s lifetime with a GA session: sessionStorage lives until the tab closes, while a GA session times out after 30 minutes of inactivity, so the two boundaries do not line up. One more sharp edge worth knowing: duplicating a tab copies its sessionStorage into the new tab, so if per-tab isolation genuinely matters, stamp each tab with its own UUID.
Conclusion
The dataLayer is page-scoped, so anything that must outlive a navigation has to be written to a cookie or to Web Storage. Use a cookie when the server needs the value, when you want cross-subdomain reach, or when the built-in GTM cookie reader is convenient. Use localStorage for data that should survive across sessions and tabs, and sessionStorage for single-session, single-tab state. Always serialize objects with JSON, guard every read against null and every parse against throwing, feature-detect storage with a cookie fallback, and gate every write behind consent. Get this right and the discount code from the campaign page is still there at checkout three pages later, which is the entire point.
[…] Data Persistence Across Pageviews in GTM […]