Flushing Stale Variables in SPAs
In a single-page application, navigating from Product A to Product B does not reload the page. GTM’s data model accumulates state across the entire session unless you explicitly clear it. Product A’s data sits in the data model when Product B’s page view fires, and any variables that read from the model return stale values.
This is not a bug in GTM — it is a feature. Persistence across events is what enables complex user journey tracking. But in SPAs, you need to actively manage what persists and what should be cleared on each navigation.
What stale variables look like in practice
Section titled “What stale variables look like in practice”Here is the sequence that causes stale data:
// User views Product AdataLayer.push({ event: 'view_item', ecommerce: { currency: 'USD', items: [{ item_id: 'A001', item_name: 'Product A', price: 49.99 }] }});
// User navigates to Product B page (SPA route change)// Your router fires a virtual pageview:dataLayer.push({ event: 'page_view', page_path: '/products/product-b', page_title: 'Product B | Example Store'});
// Note: ecommerce was NOT cleared// GTM data model still has ecommerce.items[0].item_name = 'Product A'
// Now a view_item fires for Product BdataLayer.push({ event: 'view_item', ecommerce: { currency: 'USD', items: [{ item_id: 'B001', item_name: 'Product B', price: 79.99 }] }});// This is fine — ecommerce.items was replaced because the array replaced
// BUT: if you track an event on Product B that doesn't include ecommerce:dataLayer.push({ event: 'product_interaction', interaction_type: 'zoom_image' // No ecommerce here});// If any tag on product_interaction reads ecommerce.items[0].item_name,// it gets 'Product B' — which might be correct here, but what about// the next user action after they navigate to Product C?The fragility grows with complexity. When you have multiple products, multiple event types, and some events that include product data and some that don’t, the model becomes an unpredictable mix of current and stale values.
The clearing strategy
Section titled “The clearing strategy”Principle: At every SPA route change, clear all keys that are page-specific. Retain only session-level or user-level values that should persist across navigations.
// Comprehensive SPA route change flushfunction flushDataLayerOnRouteChange() { window.dataLayer = window.dataLayer || [];
dataLayer.push({ // Clear ecommerce completely 'ecommerce': null,
// Clear page-specific product data 'item_name': undefined, 'item_id': undefined, 'item_category': undefined, 'item_brand': undefined,
// Clear page-level context 'page_type': undefined, 'page_category': undefined,
// Clear event-specific data 'interaction_type': undefined, 'search_query': undefined,
// Do NOT clear session/user data: // user_id, user_type, cart_value, etc. should persist });}Call this function at the beginning of every SPA route change, before pushing the new page’s data.
Timing: when to flush
Section titled “Timing: when to flush”The timing of the flush relative to your virtual pageview push matters.
Correct order:
// Route change detected (e.g., in a React Router afterNavigation hook)
// 1. Flush stale data firstdataLayer.push({ ecommerce: null, page_type: undefined });
// 2. Push new page datadataLayer.push({ page_path: '/new-path', page_type: 'category', category_name: 'Shoes'});
// 3. Push virtual pageview event (triggers GTM to fire page_view tag)dataLayer.push({ event: 'page_view' });If you push the flush after the pageview event, any tag triggered by page_view still sees the stale data. If you push the flush before the new page data, tags have a window where key values are undefined — acceptable if you handle default values correctly.
React Router example:
import { useEffect } from 'react';import { useLocation } from 'react-router-dom';
function useVirtualPageView() { const location = useLocation();
useEffect(() => { // Flush on route change window.dataLayer = window.dataLayer || [];
// Clear stale state window.dataLayer.push({ ecommerce: null, page_type: undefined, item_id: undefined, item_name: undefined });
// Push new pageview window.dataLayer.push({ event: 'page_view', page_path: location.pathname + location.search, page_title: document.title }); }, [location.pathname]);}Verifying the flush worked
Section titled “Verifying the flush worked”After pushing the flush, verify via the browser console:
// Check ecommerce is clearedgoogle_tag_manager["GTM-XXXX"].dataLayer.get("ecommerce")// Should return: null
// Check page_type is clearedgoogle_tag_manager["GTM-XXXX"].dataLayer.get("page_type")// Should return: undefined (not 'product' from previous page)
// Check item_name is clearedgoogle_tag_manager["GTM-XXXX"].dataLayer.get("item_name")// Should return: undefinedIf any of these return the previous page’s value, the flush either didn’t fire before GTM processed the next event, or the key path was wrong (remember, nested object paths use dot notation).
Clearing nested objects
Section titled “Clearing nested objects”Pushing undefined for a top-level key works. But what about nested keys?
// This clears the entire ecommerce key:dataLayer.push({ ecommerce: null });
// This clears only ecommerce.items but leaves other ecommerce keys:dataLayer.push({ ecommerce: { items: null } });
// This tries to clear a nested path — but creates a NEW object// that merges with the existing ecommerce object:dataLayer.push({ ecommerce: { transaction_id: undefined } });// Result: ecommerce.transaction_id is undefined,// but ecommerce.items from previous push is still thereFor complex nested clearing, use null at the level you want to clear, then re-push the values you want to keep:
// Clear ecommerce entirely, then restore currencydataLayer.push({ ecommerce: null });dataLayer.push({ ecommerce: { currency: 'USD' // items: deliberately not set — will be set when a product event fires }});Using _clear: true for batch clearing
Section titled “Using _clear: true for batch clearing”GTM supports _clear: true to reset keys atomically alongside setting new values:
// _clear: true resets all keys in the same pushdataLayer.push({ _clear: true, event: 'page_view', page_path: '/new-page', page_type: 'landing' // Any keys NOT listed here are not cleared by _clear // ecommerce from previous push: still in model // user.id from earlier push: still in model});_clear: true is useful but scoped — it only applies to keys explicitly set in the same push. It does not clear your entire data model.
The “flush all custom variables” approach for strict SPAs
Section titled “The “flush all custom variables” approach for strict SPAs”For complex SPAs where managing individual keys becomes unwieldy, some implementations push a null value for every custom variable using a registry pattern:
// Define all page-specific variables in one placeconst PAGE_SCOPED_VARIABLES = [ 'ecommerce', 'page_type', 'category_name', 'category_id', 'item_id', 'item_name', 'item_brand', 'item_category', 'search_term', 'search_results_count', 'error_code', 'error_message', 'promotion_id', 'promotion_name'];
function flushPageScopedVariables() { var flush = {}; PAGE_SCOPED_VARIABLES.forEach(function(key) { flush[key] = undefined; }); dataLayer.push(flush);}Maintain this list alongside your dataLayer specification. When a developer adds a new page-scoped variable, they add it to this array.
Common timing mistakes
Section titled “Common timing mistakes”Flushing after the route change event fires in GTM. If GTM’s History Change trigger fires before you flush, the tag on that trigger sees stale data. Always flush in a beforeEach/beforeNavigation-type hook, not an afterEach hook.
Not flushing when navigating between similar page types. Developers often test product A → category → product B and the flush works because category doesn’t push product data. But product A → product A (same page type, different product ID) skips the category page, and if you only flush on certain transitions, product A’s data can appear on the second product A view.
Pushing the flush as a separate event (triggering tags). dataLayer.push({ ecommerce: null }) does not include an event key. This is intentional — you are updating the data model without triggering any GTM event. If you accidentally include event: 'flush' in your clearing push, you may trigger unwanted GTM tags.
// Wrong: triggers GTM event evaluationdataLayer.push({ event: 'clear', ecommerce: null });
// Correct: updates model without triggering evaluationdataLayer.push({ ecommerce: null });GTM Preview mode diagnosis
Section titled “GTM Preview mode diagnosis”In GTM Preview mode, you can see the data model state at any point by clicking an event and checking the Variables tab. The Variables tab shows the current value of each GTM variable at the moment that event was processed.
If you see a stale value for a Data Layer Variable at a specific event, work backward in the timeline:
- Find the event where the stale value was first set
- Look for a flush push between that event and the current one
- If no flush exists, you found your missing cleanup
The absence of a flush is the most common finding. The fix is always the same: add dataLayer.push({ [key]: undefined }) before the event that should not have the stale value.