Experiment / Vary Site Content with GTM
Experiment / Vary Site Content with GTM — A Complete Recipe with a Transparent Pixel and a Custom Tag Template
Randomly bucket visitors into a persistent variation, swap page content per bucket with zero flicker, and log the exposure event to GA4 — using a first-party cookie, one Custom Template tag, and a Custom HTML tag. No Optimizely. No Adobe Target. Free.
You can deploy a genuine A/B content experiment on a website with nothing more than Google Tag Manager and a small amount of JavaScript. For illustration, this recipe swaps an image on a page for different visitors and sends the assigned "variation" to GA4 as an event — but the same pattern works for headlines, CTAs, pricing blocks, or any other DOM content. The container below sits alongside the same Analytics infrastructure (client ID, session ID, environment routing, shared event settings) used across this blog's other recipes, extended with one new folder: Personalization and Experiments.
⬇ Download the GTM Container JSON from Google DriveThis is the live result of the solution below: a transparent 1×1 default swapped, client-side, for one of four bucketed images — no flash of the wrong image on load.
Why a Transparent Pixel Default Avoids the "Flicker Effect"
When you swap DOM content client-side, there is an inescapable gap between "first paint" and "the moment your GTM tag fires and rewrites the element." If the page's default HTML shows the control image (or control text) during that gap, the visitor sees the original content flash briefly before the variation replaces it. This is the flicker effect — sometimes called Flash of Original Content (FOOC) — and it is the single biggest complaint about DIY client-side experiment tooling versus paid platforms with edge-side or synchronous-snippet delivery.
The fix used here costs nothing and needs no server changes: make the page's default src a 1×1 transparent pixel instead of any real variation. A transparent pixel scaled to a 320×320 box is visually indistinguishable from "nothing rendered yet," so however long the DOM Ready trigger takes to fire, the visitor never sees a wrong image — only a brief blank space, which reads as normal image-loading latency rather than a jarring swap.
src is the transparent pixel. The Custom HTML tag described below finds that exact URL on the page and rewrites it to one of four real images, based on the visitor's assigned bucket. If you're reading this without JavaScript execution (or if the tag hasn't fired yet), what you're looking at is the transparent placeholder — proof that the base state is invisible by design.How the Experiment Works, End to End
- Bucket assignment (page load): A GA4 Initialization-scoped trigger checks whether the visitor already has an
_i_rnd_facecookie. If not, a Custom Template tag generates a random integer between 0–3, writes it to a first-party, root-domain-scoped cookie (persistent, 720 days), and pushes an{event: "_i_rnd_face", randomInteger: N}object to the dataLayer. - Content swap (DOM Ready, page-scoped): A Custom HTML tag, scoped to this specific post's URL path, finds the
<img>and its wrapping<a>whosesrc/hrefstart with the transparent-pixel base URL, and rewrites just the base — preserving the original Blogger size suffix (=w320-h320,=s1, etc.) — to one of four real image base URLs looked up from the cookie value. - Exposure logging (custom event): A custom event trigger fires whenever a dataLayer event name matches the cookie name, which sends a GA4 event named
experimentcarrying the chosen bucket as both an event parameter and a user property, so you can build funnels and audiences segmented by which variation a visitor saw.
What's Inside the Container
The exported container (experiment-vary-site-content-with-gtm.json) carries over the same Analytics folder used elsewhere on this blog (GA4 configuration, shared settings, client ID/session ID/timestamp utilities, environment routing) and adds one new folder purpose-built for this experiment.
Folders
| Folder | Contains |
|---|---|
Analytics | GA4 configuration tag, shared configuration/event settings variables, environment- and stream-routing variables, client ID and session ID utilities — carried over from the base container. |
Personalization and Experiments | The random-bucket cookie tag, the image-swap Custom HTML tag, the GA4 exposure event tag, and every trigger and variable specific to this experiment. |
Tags
| Tag | Type | Fires On | Purpose |
|---|---|---|---|
Google Analytics Configuration | Google Tag (googtag) | Initialization / All Pages, once per load | Standard GA4 config tag wired to environment-aware Stream IDs via the shared configuration and event settings variables. |
Random Integer Cookie and dataLayer Push Tag | Custom Template (Random Integer Cookie and dataLayer Push) | Random Integer Cookie and dataLayer Push Initialize — Initialization trigger, filtered so it only runs when the cookie is not yet set | Generates a random integer 0–3, writes the persistent first-party bucket cookie, pushes the dataLayer event that later triggers the GA4 exposure tag. |
Randomize Face Image | Custom HTML | Randomize Face DOM Ready — DOM Ready, filtered to this post's page path | Finds the transparent-pixel <img>/<a> on the page and rewrites the base URL to the bucketed image, preserving the Blogger size suffix. |
Randomize Face Send Event | GA4 Event (gaawe) | Random Integer Cookie and dataLayer Push Event — Custom Event trigger matching the cookie-name dataLayer push | Sends a GA4 experiment event with the chosen bucket as an event parameter (chosenFace) and a matching user property, so exposure can be segmented and funnelled in GA4. |
Triggers
| Trigger | Type | Filter |
|---|---|---|
Random Integer Cookie and dataLayer Push Initialize | Initialization | Fires only when {{Random Face Cookie Value}} matches ^(undefined|null|false|NaN|)$ — i.e. only when no bucket cookie exists yet. |
Randomize Face DOM Ready | DOM Ready | Fires only when {{Page Path}} starts with /2011/01/experiment-vary-site-content-with-gtm.html. |
Random Integer Cookie and dataLayer Push Event | Custom Event | Custom event name matches .* (any event), additionally filtered so {{is Random Face Cookie Name}} equals true — i.e. only when the fired event name equals the bucket cookie's name. |
Randomize Face DOM Ready trigger's page-path filter (/2011/01/experiment-vary-site-content-with-gtm.html) is hard-coded to this post's URL. If you import this container into your own workspace, change that filter to the path of the page where your own transparent-pixel placeholder actually lives — otherwise the DOM Ready tag will never fire.Variables — Analytics (shared, carried over from the base container)
| Variable | Type | Notes |
|---|---|---|
Browser Client ID / Browser Session ID | Google Analytics: Settings field (analytics_storage) | Pulled straight from the Google-managed cookie fields, client_id and session_id. |
Browser Client ID Append Underscore | Custom Template (If Else If — Advanced Lookup Table) | Appends a trailing underscore, or substitutes an explicit "undefined" value, so BigQuery stores the client ID as a string instead of losing precision as a float. |
Browser Session ID RegEx Table / Measurement Stream ID RegEx Table | RegEx Table | Extract the numeric session ID and the G-XXXXXXX stream ID out of the raw _ga_<STREAM> cookie value. |
Timestamp Current | Custom Template (Timestamp) | Returns getTimestampMillis() — the current time in epoch milliseconds. |
Timestamp First User / Timestamp Session Start | Custom Template (If Else If — Advanced Lookup Table) | Zero-pads the millisecond-truncated timestamps pulled from the client ID and session cookies back out to full epoch milliseconds. |
Environment Stream ID / Measurement Stream RegEx Lookup | RegEx Table | Route to the Development or Production GA4 Measurement ID constant based on GTM Environment name and/or hostname pattern matching. |
Root Domain | URL (HOST component, strip www) | Used to scope the bucket cookie. Note in the export: the "Get Root Domain" custom-template version (below) did not resolve correctly on *.blogspot.com, so this simpler hostname-based variable is used for cookie-domain purposes instead. |
Root Domain - Old | Custom Template (Get Root Domain) | Left in the container for reference/portability to non-Blogger platforms (AEM, Sitecore, WordPress, React, etc.) where true eTLD+1 extraction is needed for cross-subdomain cookies. |
Measurement Stream ID Development / Measurement Stream ID Production | Constant | Placeholder GA4 Measurement IDs (G-ZZZZZZZZZZ / G-AAAAAAAAAA) — replace with your real Stream IDs on import. |
Google Tag Shared Configuration Settings | Google Tag: Config Settings | debug_mode: true, send_page_view: false — page views are governed elsewhere in the base container. |
Google Tag Shared Event Settings | Google Tag: Event Settings | Merges e_client_id, container_id, environment_name, stream_id, e_1st_session_ts, e_cur_session_ts, e_1st_user_ts, and page_title onto every GA4 hit sent by the configuration tag. |
Random Number | Random Number (built-in generator) | Documented as the tag's randomSeed input — see the Custom Template notes below on why this is illustrative rather than a literal seed. |
GTM Container ID / GTM Environment Name / Undefined Value / Page Title | Built-in / Custom JS / Constant | Standard container utility variables reused across this blog's recipes. |
Variables — Personalization and Experiments (new in this container)
| Variable | Type | Notes |
|---|---|---|
Random Face Cookie Name | Constant | Value: _i_rnd_face. Doubles as both the cookie name and the dataLayer event name pushed by the bucketing tag. |
Random Face Cookie Value | 1st-Party Cookie | Reads {{Random Face Cookie Name}} directly from document.cookie. |
is Random Face Cookie Name | Lookup Table | Maps {{Event}} to true only when it equals {{Random Face Cookie Name}}, default false. This is what lets the Custom Event trigger fire only for this experiment's own dataLayer push and not every other custom event on the page. |
Randomize Face Image Cookie Lookup | Lookup Table | Maps the bucket cookie value (0, 1, 2, 3) to one of four real Blogger-hosted image base URLs. The table's own default/undefined/true/false fallbacks all resolve back to the transparent-pixel base URL, so any unexpected cookie value degrades safely to "no visible change" rather than a broken image. |
dataLayer randomInteger Value | Data Layer Variable | Reads randomInteger (dataLayer version 2) from the event pushed by the bucketing tag — available for debugging/Preview mode inspection. |
Custom Templates Used by This Solution
Four Custom Templates power this container — three pulled from the Community Template Gallery for general-purpose utility, and one built specifically for this recipe.
| Template | Kind | Source | Role in This Solution |
|---|---|---|---|
| Random Integer Cookie and dataLayer Push | Tag | Custom-built for this recipe (not gallery-published) | The core of the experiment. Checks for an existing bucket cookie (idempotent — does nothing if one already exists), computes a random integer in [0, range] using GTM's sandboxed Math.random(), writes a first-party cookie via the sandboxed setCookie API (Secure, SameSite=Lax, optional max-age for persistence), and pushes {event, randomInteger} to the dataLayer via createQueue('dataLayer'). Ships with its own unit-test scenarios (idempotency, session vs. persistent cookie, range boundaries). |
| If Else If — Advanced Lookup Table | Variable | Gallery — github.com/sublimetrix/gtm-template-ifelseif | General-purpose multi-condition lookup (equals / contains / starts-with / regex / numeric comparisons) used for the BigQuery-safe client ID formatting and the timestamp zero-padding variables. |
| Timestamp | Variable | Gallery — github.com/luratic/Timestamp | Thin wrapper around the sandboxed getTimestampMillis() API, used for the "current time" shared-event-settings parameter. |
| Get Root Domain | Variable | Gallery — github.com/mbaersch/get-root-domain | eTLD+1 extraction utility. Kept in the container for portability to non-Blogger platforms, per the in-container note that it doesn't resolve correctly against *.blogspot.com hostnames. |
The Image-Swap Custom HTML Tag
This is the piece doing the actual DOM rewrite on DOM Ready. It intentionally touches nothing but the src/href attributes of the elements whose current value starts with the known transparent-pixel base URL — leaving Blogger's own size-suffix query string intact so the image still renders at the dimensions Blogger expects.
<script>
var oldBase = "https://blogger.googleusercontent.com/img/a/AVvXsEierEu8lbNxsfUf...K9LM540AYfh";
var newBase = {{Randomize Face Image Cookie Lookup}};
try {
var faceSrc = document.querySelector('img[src^="' + oldBase + '"]');
if (faceSrc) {
var oldSrc = faceSrc.getAttribute("src");
var suffix = oldSrc.substring(oldBase.length); // e.g. "=w320-h320"
faceSrc.setAttribute("src", newBase + suffix);
}
} catch (err) { }
try {
var faceHref = document.querySelector('a[href^="' + oldBase + '"]');
if (faceHref) {
var oldHref = faceHref.getAttribute("href");
var suffix = oldHref.substring(oldBase.length); // e.g. "=s1"
faceHref.setAttribute("href", newBase + suffix);
}
} catch (err) { }
</script>
oldBase is hard-coded to this blog's transparent-pixel image URL. For your own implementation, replace it with the base URL of whatever 1×1 transparent placeholder you install as the default image on your page (and update the four entries — plus the default/fallback values — in the Randomize Face Image Cookie Lookup variable to point at your own real image variants).Extending the Pattern to Text Content
The same flicker-free approach works for swapping text instead of an image — headlines, CTA copy, pricing callouts, banner messaging. Instead of matching against an image URL, target an empty (or default-copy) <div> by a stable id, and write the bucketed string into it on DOM Ready:
<!-- On the page: an empty container that starts with safe default copy -->
<div id="experiment-headline">Welcome to our site</div>
<script>
// {{Randomize Headline Text Cookie Lookup}} is a Lookup Table variable,
// keyed on the same bucket cookie value (0-3), returning the variant copy.
var headlineText = {{Randomize Headline Text Cookie Lookup}};
try {
var headlineEl = document.getElementById('experiment-headline');
if (headlineEl && headlineText) {
headlineEl.textContent = headlineText;
}
} catch (err) { }
</script>
Wire this as a second Custom HTML tag on the same Randomize Face DOM Ready trigger (or a copy of it scoped to the page carrying the text experiment). Build the companion Lookup Table variable exactly like Randomize Face Image Cookie Lookup, but map each bucket value to a text string instead of an image base URL, and set its default/fallback to your safe, default on-page copy rather than an empty string — that default copy plays the same role the transparent pixel plays for images: it is what every visitor sees for the brief moment before the DOM Ready tag fires, so it must never look broken or blank.
Why This Is a Free Alternative to Optimizely and Adobe Target
Commercial experimentation platforms bundle bucketing, exposure logging, statistical analysis, and (in the best cases) synchronous/edge-side content delivery into a paid SaaS product. For a huge share of real-world experiments — a single element swap, a handful of buckets, no need for edge-side rendering — this GTM-only pattern gets you the same practical outcome at zero incremental cost:
- Bucketing: a persistent, first-party cookie generated once per visitor via sandboxed GTM APIs — no vendor script, no extra network request.
- Content delivery: a DOM Ready Custom HTML tag, scoped to exactly the page(s) you're testing — no vendor snippet blocking render.
- Exposure logging and analysis: a standard GA4 event with the bucket as both an event parameter and a user property, so every GA4 report, Exploration, and BigQuery export you already use works unmodified.
- Flicker mitigation: a transparent-pixel (or safe-default-copy) starting state, instead of a paid platform's anti-flicker snippet.
What you give up versus a dedicated platform: built-in statistical significance calculators, multi-armed bandit traffic allocation, and true edge/server-side rendering for zero-flicker at any connection speed. For a blog, a marketing site, or a lower-traffic funnel, that trade is often well worth the savings.
How to Import
- Download the JSON file from the Google Drive link.
- In GTM, go to Admin → Import Container.
- Upload
experiment-vary-site-content-with-gtm.json. - Choose Merge (not Overwrite) to preserve your existing tags and triggers.
- Update
Measurement Stream ID ProductionandMeasurement Stream ID Developmentto your actual GA4 Measurement IDs. - Update the
Randomize Face DOM Readytrigger's page-path filter to match the page where your own transparent-pixel placeholder lives. - Install your own 1×1 transparent placeholder image on that page, and update
oldBasein theRandomize Face Imagetag plus every entry in theRandomize Face Image Cookie Lookupvariable to reference your own placeholder and variant image URLs. - In GA4, confirm
chosenFaceis registered as a custom event-scoped dimension and user property under Admin → Custom definitions if you want it available for segmenting and Explorations. - Open GTM Preview mode, clear your test cookies, reload the page a few times, and confirm the bucket cookie, the DOM swap, and the
experimentGA4 event all fire correctly before publishing.
What You Can Do With It
- Compare engagement or conversion rate across the four buckets in GA4 Explorations, segmented by the
chosenFaceuser property. - Extend the Lookup Table variable from four buckets to any number of variations — the random-integer range is a template parameter on the tag, not hard-coded.
- Run the same pattern concurrently on multiple elements (image + headline + CTA) by reusing the same bucket cookie across multiple Lookup Table variables, so a visitor's experience stays consistent across every swapped element.
- Swap the Custom HTML DOM-content approach for a Custom Template tag of your own if you want the logic to ship with typed parameters and unit tests, the way the bucketing tag does.
The full source — including the container JSON, this write-up, and the text-swap example — is available on GitHub. If you adapt this to additional experiment types, open a pull request or issue.
⬇ Download GTM Container JSON
Comments
Post a Comment