Analytics and Tag Manager Memory Growth

Your own code is leak-free, yet after two hours in the single-page app the heap has grown by 150 MB, and snapshots show a dataLayer array with 20,000 entries, a session-replay buffer full of serialised DOM mutations, and a chat widget’s message history. This guide from Third-Party Library Memory Leaks, part of Framework-Specific Memory Optimization, shows how to measure the memory owned by third-party scripts, which integration habits make it grow, and how to keep it bounded without losing the data your business needs.

Symptom Root Cause Immediate Action Measurable Impact
window.dataLayer grows with every route and click Every push stays in the array for the session Push small event objects; avoid pushing full state Growth per event cut to bytes
Large product lists retained by analytics E-commerce events push whole catalogues or responses Push IDs and minimal fields only Megabytes per event avoided
Heap grows fastest on DOM-heavy pages Session replay records DOM snapshots and mutations Configure sampling, masking and buffer limits Replay memory bounded or zero for most sessions
Memory grows with SPA navigations only when tags are loaded Tags re-initialise or add listeners per virtual page view Fire a single page-view event; avoid re-injecting tags Constant listener count
Third-party share unknown No measurement with and without vendors Compare heap in a clean profile with tags blocked Vendor cost quantified

Root Cause: Scripts That Grow With the Session

Third-party scripts run in your page’s heap and share its limits. In a traditional multi-page site, their memory resets on every navigation. In a single-page app, the page lives for hours, and any state a script accumulates grows for the whole session — exactly like module-level caches and global singleton leaks in your own code, but in code you did not write.

The tag manager data layer is the most common example. window.dataLayer is an ordinary array; every dataLayer.push(...) appends an object that stays there for the session, and the tag manager also keeps its own internal model of the data. Pushing a small event ({ event: 'add_to_cart', item_id: 'SKU-1' }) costs bytes. Pushing an entire API response, a full product catalogue for a list view, or the application state on every route change costs megabytes per push, all retained. Virtual page views in SPAs multiply the number of pushes.

Session-replay and heatmap tools record the DOM: an initial snapshot plus a stream of mutations, inputs and scroll events, buffered in memory and flushed in batches. On DOM-heavy pages — large tables, virtualised lists that constantly add and remove rows, animated dashboards — the mutation stream is large, and if flushing falls behind (network slow, tab backgrounded) the buffer grows. Chat widgets, A/B testing and personalisation SDKs keep histories, experiment state and observers of their own. Some of them inject iframes; others observe DOM mutations to re-apply changes after your framework re-renders.

None of this is visible in your source code, and it varies by environment: marketing may add tags through the tag manager without a deploy. The only reliable approach is to measure — compare memory with and without third-party scripts in the same scenario — and to govern integrations: what data may be pushed, which tools may run on which pages, and at what sampling rates.

Where a long session's heap goes After a two hour session, heap attributed to the application is 90 megabytes, the tag manager data layer 55 megabytes because full product lists were pushed, session replay buffers 70 megabytes, and a chat widget 20 megabytes, for 235 megabytes in total. After pushing only IDs, sampling session replay to 10 percent of sessions and lazy-loading chat, the same session uses 90, 3, 0 and 4 megabytes, 97 megabytes in total. Heap after a 2-hour session (MB) Before 235 MB After 97 MB application data layer session replay chat widget illustrative figures from one app; measure yours with tags blocked versus loaded

Step-by-Step Fix

  1. Measure with and without third parties. Run the same scripted long session twice in a clean profile: once normally, once with third-party domains blocked (DevTools Network request blocking or a Playwright route that aborts vendor URLs). Verification: the heap difference is the third-party share.
  2. Attribute by snapshot. In the heap snapshot of the normal run, sort by retained size and look for dataLayer, vendor global objects and large arrays retained by vendor scripts (Group by URL in allocation profiles also helps). Verification: you know which vendor holds how much.
  3. Shrink data layer pushes. Replace pushes of whole objects with minimal event payloads — IDs, categories, amounts — and push page views once per virtual navigation. Verification: JSON.stringify(window.dataLayer).length grows by bytes, not kilobytes, per event.
  4. Configure session replay. Enable sampling (record only a percentage of sessions), mask or ignore heavy DOM regions (large tables, canvases), and make sure buffers flush or cap. Verification: replay-related memory is zero in unsampled sessions and bounded in sampled ones.
  5. Load widgets on demand. Lazy-load chat and support widgets on user intent (clicking “Help”) rather than on every page. Verification: sessions that never open chat carry none of its memory.
  6. Govern and re-measure regularly. Keep a list of approved tags and data layer schemas, and re-run the with/without measurement after tag changes. Verification: third-party share stays within an agreed budget.
dataLayer size across 200 navigations Pushing the full product list with each list view makes the data layer grow to about 55 megabytes of serialised data after two hundred navigations. Pushing only event names and item identifiers keeps it under 1 megabyte. 55 MB 0 full product lists per push event name + item IDs only SPA navigations (0 → 200)

Command and Code Reference

Use case: measure third-party share with Playwright by blocking vendor domains.

// third-party-share.spec.mjs
import { test } from '@playwright/test';

const VENDORS = [/googletagmanager\.com/, /google-analytics\.com/, /replay\.vendor\.example/, /chat\.vendor\.example/];

async function sessionHeap(page, context, block) {
  if (block) await page.route('**/*', (r) => (VENDORS.some((v) => v.test(r.request().url())) ? r.abort() : r.continue()));
  await page.goto('/');
  for (let i = 0; i < 100; i++) await browseOneProduct(page);     // scripted long session
  const cdp = await context.newCDPSession(page);
  await cdp.send('HeapProfiler.collectGarbage');
  return (await cdp.send('Runtime.getHeapUsage')).usedSize / 1048576;
}

test('third-party heap share', async ({ browser }) => {
  const a = await browser.newContext(); const withTags = await sessionHeap(await a.newPage(), a, false);
  const b = await browser.newContext(); const noTags = await sessionHeap(await b.newPage(), b, true);
  console.log(`third-party share: ${(withTags - noTags).toFixed(1)} MB`);
});

Use case: a small, typed data layer helper that prevents large pushes.

// analytics.js — the only place that talks to window.dataLayer
const MAX_PAYLOAD_CHARS = 2000;

export function track(event, fields = {}) {
  const payload = { event, ...fields };
  const size = JSON.stringify(payload).length;
  if (size > MAX_PAYLOAD_CHARS) {
    console.warn(`[analytics] ${event} payload ${size} chars — send IDs, not objects`);
    return;                                   // refuse oversized pushes in all environments
  }
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push(payload);
}

// track('view_item_list', { list_id: 'summer', item_ids: items.map((i) => i.id).slice(0, 50) });

Verification and Regression Prevention

The fix is verified when the with/without comparison shows a third-party share within your budget after a long scripted session, the data layer grows by small amounts per event, and session-replay memory is bounded (or absent in unsampled sessions). Re-run the comparison after every tag manager publication or vendor SDK upgrade, because third-party code changes without your deploys.

Make the measurement part of your performance monitoring: a nightly job running the long-session script with and without vendors, alerting when the share rises. In production, field memory telemetry — see sampling memory telemetry without hurting performance — segmented by whether heavy tools like session replay were active, shows the real-world cost. Agree ownership with marketing and product teams so tag changes include a memory check.

Re-checking third-party share after changes After every tag manager publication or vendor SDK upgrade, run the same long scripted session with and without third-party tags, compare the heap difference against your budget, check the data layer grows only by small amounts per event, and confirm session replay memory is bounded or absent in unsampled sessions. Tag publish or SDK upgrade the trigger With vs without same long scripted session Share in budget? third-party heap difference dataLayer + replay small per event; bounded

Edge Cases and Gotchas

The data layer is an array you can read

Because window.dataLayer is plain JavaScript, it is easy to inspect in the Console: window.dataLayer.length and JSON.stringify(window.dataLayer).length give a quick size check during development.

Truncating the data layer

Removing entries from dataLayer may break tags that depend on earlier values, and the tag manager keeps its own internal state anyway. Prevent growth by pushing less rather than by deleting entries.

Background tabs and replay buffers

When a tab is hidden, network flushing may be throttled while recording continues or pauses depending on the tool. Check the vendor’s behaviour for hidden tabs; long-hidden SPA tabs are a common source of replay buffer growth.

Web workers and off-main-thread tags

Running tags in a worker (for example with tools that proxy third-party scripts off the main thread) moves CPU work away from the UI, but the memory still belongs to your page’s process. Measure it the same way.

Frequently Asked Questions

Does Google Tag Manager’s dataLayer cause memory leaks?

It grows with every push for the life of the page. In single-page apps with many events or large payloads that becomes significant. Pushing small event objects keeps it negligible; pushing whole API responses or product lists makes it a leak in practice.

How do I know how much memory third-party scripts use?

Run the same scripted session with third-party domains blocked and with them loaded, forcing garbage collection before each measurement. The difference is the third-party share. Heap snapshots of the loaded run then show which objects hold it.

Is session replay safe for memory?

It records DOM changes and buffers them before sending, so memory depends on how DOM-heavy your pages are and how reliably data is flushed. Use sampling, mask heavy regions, and verify buffer behaviour on long sessions and in background tabs.

Should chat widgets load on every page?

Only if most users need them. Lazy-loading chat on user intent avoids its scripts, iframes and history for everyone who never opens it, which is usually the majority of sessions.

Who should own third-party memory budgets?

Engineering should measure and enforce them, but tags are often added by marketing or product teams. Agree a shared budget and a review step for new tags, and automate the with/without measurement so changes are visible.