4. GTM from the Ground Up

Accounts, containers, workspaces and versions; what the GTM snippet does line by line; what gtm.js actually contains; and the exact page-load sequence.

You've pasted the GTM snippet into a dozen sites. This chapter is about what you pasted: the object model behind the console, what those two <script> blocks actually execute, what's inside gtm.js, and the precise order things happen in when a page loads. Once you've internalized this, half of GTM's "weird behavior" stops being weird.

The object model

Google account (your login)
└── GTM Account            — one per company, usually
    └── Container          — one per site or app
        │                    types: Web · Server · iOS · Android · AMP
        ├── Workspaces     — parallel drafts (max 3 on free GTM)
        ├── Versions       — immutable published snapshots
        └── Environments   — Live · staging · preview links
  • Account — the company-level grouping. The practical advice: clients should own their GTM account and grant your agency/user access — moving a container between accounts later is painful.
  • Container — one tag configuration for one property. The container ID (GTM-XXXXXXX) is what the snippet references. Note the Server container type sitting there next to Web — that's Part 3 of this guide; everything in Part 2 is about Web containers.
  • Workspace — a draft. Think of it as an extremely lite git branch: you edit in a workspace, see a change list, and publishing merges it into a new version. If a colleague publishes first, GTM forces you to sync (rebase, roughly) and resolve conflicts field by field. Free GTM allows 3 concurrent workspaces; the paid 360 tier, unlimited.
  • Version — an immutable snapshot created when you publish (or "create version" without publishing). Rollback is just re-publishing an older version — cheap, instant, and the reason bold edits are safe.
  • Environment — by default just Live, but you can create e.g. Staging, publish a version to it, and load that environment on your staging site with a modified snippet. Teams that QA tracking like code use this; most small setups never touch it.

One habit worth stealing from software: name versions like commits ("GA4 ecommerce + LinkedIn insight, checkout fix"), because six months later the version list is your only changelog.

The snippet, line by line

Here's the standard head snippet, de-minified:

(function (w, d, s, l, i) {
  // 1. make sure window.dataLayer exists
  w[l] = w[l] || [];
 
  // 2. record the moment GTM started, and queue a 'gtm.js' event
  w[l].push({ "gtm.start": new Date().getTime(), event: "gtm.js" });
 
  // 3. inject an async <script> that loads your container
  var f = d.getElementsByTagName(s)[0],
      j = d.createElement(s),
      dl = l != "dataLayer" ? "&l=" + l : "";
  j.async = true;
  j.src = "https://www.googletagmanager.com/gtm.js?id=" + i + dl;
  f.parentNode.insertBefore(j, f);
})(window, document, "script", "dataLayer", "GTM-XXXXXXX");

That's all it is: ensure the queue exists, drop a start marker into it, load the container script asynchronously. Three details earn their keep:

  • async = true — the page keeps parsing while gtm.js downloads. GTM does not block rendering; a slow container delays tags, not content.
  • The &l= parameter — you can rename dataLayer (rarely wise; every integration assumes the default name).
  • Anything pushed to dataLayer before the snippet is preserved — the snippet does w[l] = w[l] || [], keeping an existing array. This is exactly how you hand GTM data that must exist at page load (Chapter 5 leans on this hard).

The second snippet — the <noscript> iframe pasted after <body> — exists only for browsers with JavaScript disabled. It can't run tags (no JS!); it loads a fallback that registers little more than a hit. Today it's essentially ceremonial; include it, but never expect anything from it.

What gtm.js actually is

This is the mental unlock of the chapter: gtm.js is your container, compiled to JavaScript. When you click Publish, GTM takes every tag, trigger, and variable in the version and bakes them into a single script — the GTM runtime plus a big embedded resource describing your configuration. Open https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX in a browser and you can read it: near the top sits something like

var data = {
  "resource": {
    "version": "742",
    "macros":   [ ... ],   // ← your variables (internal name: macros)
    "tags":     [ ... ],   // ← your tags
    "predicates": [ ... ], // ← trigger conditions
    "rules":    [ ... ]    // ← trigger → tag wiring (internal name: rules)
  }
};

The old internal names survive in the compiled output: macros are variables, rules are triggers. Three consequences follow immediately:

  1. A container has no secrets. Every Custom JavaScript variable, every API key you paste into a tag field, ships in plaintext to every visitor. If a credential ever feels like it belongs in a web container, the answer is a server container (Part 3) — server-side config never reaches the browser.
  2. Container size is page weight. Every tag and variable makes gtm.js bigger. Hundreds of zombie tags don't just clutter the console — every visitor downloads them. Audit and delete.
  3. Publishing is fast but not instant. The compiled script is served from Google's CDN with short-lived HTTP caching, so a new version typically reaches visitors within minutes — but an open tab that loaded gtm.js an hour ago keeps running the old version until reload. "I published but nothing changed" is usually cache, not failure.

The load sequence

Exact order, every page load:

0. (optional) your code pushes to dataLayer BEFORE the snippet
   → messages wait in the plain array
 
1. snippet runs
   → dataLayer exists, {event:'gtm.js', gtm.start} queued
   → async download of gtm.js begins
 
2. gtm.js arrives, runtime boots, and replaces dataLayer.push
   with its own processor (Chapter 5), then drains the queue IN ORDER:
     ├─ Consent Initialization  ← earliest trigger type; CMPs fire here
     ├─ Initialization          ← "before everything else" tags
     └─ event: gtm.js           ← the "Page View" trigger type ✱
3. DOM fully parsed   → push {event:'gtm.dom'}   → "DOM Ready" triggers
4. page fully loaded  → push {event:'gtm.load'}  → "Window Loaded" triggers
 
…then the page lives: clicks, form submits, history changes, and your
custom events each push more messages — every push re-evaluates triggers.

✱ The naming trap that bites every newcomer: the Page View trigger fires at gtm.js time — the moment the container boots — not when the page finishes rendering. The DOM may be half-parsed; elements your tag wants to read may not exist yet. That's what DOM Ready is for. Conversely, anything that should beat the visitor's first interaction (consent defaults!) belongs in Consent Initialization / Initialization, which exist precisely because gtm.js was historically the earliest hook and it wasn't early enough.

The other timing fact people learn the hard way: a visitor who bounces in two seconds may leave after the snippet ran but before gtm.js finished downloading on a slow connection — the tags never fire, the pageview never happens. Tag-side data loss isn't only blocking (Chapter 3); some of it is plain physics. It's also why the snippet belongs high in <head>, not at the bottom of <body>.

GTM vs gtag.js (and the Google tag)

Google ships two loaders, and mixing them up causes real damage:

  • gtag.js — the per-product snippet ("Google tag", IDs like G-XXXX / AW-XXXX). Configuration lives in code: gtag('config', …), gtag('event', …). Right choice for a one-product site with developers who prefer code.
  • GTM — the full console. The GA4 and Ads tags inside GTM are built on the same underlying Google-tag machinery, so GTM supersedes gtag.js — anything gtag.js can send, a GTM setup can send.

The two keep converging: Google has been folding GTM containers and Google tags into one model (shared Google-tag settings, dual-ID deployments, visual point-and-click tagging). Expect the console to keep shifting — the mechanics in this chapter are the part that survives.

Both speak through dataLayer (a gtag() call is literally a dataLayer.push of an arguments object), so they can coexist — but never install the same product through both. A site with a hardcoded gtag.js GA4 snippet and a GA4 tag in GTM double-counts every pageview, and that one's always fun to discover in an audit.

Container hygiene

Conventions that keep a container maintainable once it passes ~20 tags — and that make our own client handovers sane:

  • Name by platform – purpose – location: GA4 – Event – purchase, Meta – Pixel – Base, LI – Insight – Site-wide. The console sorts alphabetically; platform-first naming groups for free.
  • Folders for platform or workstream; ruthlessly few.
  • No zombie tags: pausing is for experiments; anything dead for a quarter gets deleted — versions remember it forever anyway.
  • One container per site. Multi-site businesses sometimes share one container for "efficiency"; they pay in trigger spaghetti (Page Hostname equals … on everything) and cross-site breakage. (Large enterprises use Zones — a 360 feature — to compose containers instead.)

Next, the half of GTM that actually carries your data — the thing the snippet created in line one: Chapter 5 — The Data Layer.