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.

Published · Tags: GTM, Google Analytics 4, Content Experiments, A/B Testing, Custom Templates, Cookies, dataLayer, Flicker Effect, CSP, GA4

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 Drive

This 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.

The image block above is the default. Its 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

  1. Bucket assignment (page load): A GA4 Initialization-scoped trigger checks whether the visitor already has an _i_rnd_face cookie. 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.
  2. 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> whose src/href start 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.
  3. Exposure logging (custom event): A custom event trigger fires whenever a dataLayer event name matches the cookie name, which sends a GA4 event named experiment carrying 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

FolderContains
AnalyticsGA4 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 ExperimentsThe 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

TagTypeFires OnPurpose
Google Analytics ConfigurationGoogle Tag (googtag)Initialization / All Pages, once per loadStandard GA4 config tag wired to environment-aware Stream IDs via the shared configuration and event settings variables.
Random Integer Cookie and dataLayer Push TagCustom 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 setGenerates 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 ImageCustom HTMLRandomize Face DOM Ready — DOM Ready, filtered to this post's page pathFinds 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 EventGA4 Event (gaawe)Random Integer Cookie and dataLayer Push Event — Custom Event trigger matching the cookie-name dataLayer pushSends 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

TriggerTypeFilter
Random Integer Cookie and dataLayer Push InitializeInitializationFires only when {{Random Face Cookie Value}} matches ^(undefined|null|false|NaN|)$ — i.e. only when no bucket cookie exists yet.
Randomize Face DOM ReadyDOM ReadyFires only when {{Page Path}} starts with /2011/01/experiment-vary-site-content-with-gtm.html.
Random Integer Cookie and dataLayer Push EventCustom EventCustom 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.
Update this before you publish on your own site. The 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)

VariableTypeNotes
Browser Client ID / Browser Session IDGoogle Analytics: Settings field (analytics_storage)Pulled straight from the Google-managed cookie fields, client_id and session_id.
Browser Client ID Append UnderscoreCustom 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 TableRegEx TableExtract the numeric session ID and the G-XXXXXXX stream ID out of the raw _ga_<STREAM> cookie value.
Timestamp CurrentCustom Template (Timestamp)Returns getTimestampMillis() — the current time in epoch milliseconds.
Timestamp First User / Timestamp Session StartCustom 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 LookupRegEx TableRoute to the Development or Production GA4 Measurement ID constant based on GTM Environment name and/or hostname pattern matching.
Root DomainURL (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 - OldCustom 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 ProductionConstantPlaceholder GA4 Measurement IDs (G-ZZZZZZZZZZ / G-AAAAAAAAAA) — replace with your real Stream IDs on import.
Google Tag Shared Configuration SettingsGoogle Tag: Config Settingsdebug_mode: true, send_page_view: false — page views are governed elsewhere in the base container.
Google Tag Shared Event SettingsGoogle Tag: Event SettingsMerges 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 NumberRandom 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 TitleBuilt-in / Custom JS / ConstantStandard container utility variables reused across this blog's recipes.

Variables — Personalization and Experiments (new in this container)

VariableTypeNotes
Random Face Cookie NameConstantValue: _i_rnd_face. Doubles as both the cookie name and the dataLayer event name pushed by the bucketing tag.
Random Face Cookie Value1st-Party CookieReads {{Random Face Cookie Name}} directly from document.cookie.
is Random Face Cookie NameLookup TableMaps {{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 LookupLookup TableMaps 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 ValueData Layer VariableReads 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.

TemplateKindSourceRole in This Solution
Random Integer Cookie and dataLayer PushTagCustom-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 TableVariableGallery — github.com/sublimetrix/gtm-template-ifelseifGeneral-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.
TimestampVariableGallery — github.com/luratic/TimestampThin wrapper around the sandboxed getTimestampMillis() API, used for the "current time" shared-event-settings parameter.
Get Root DomainVariableGallery — github.com/mbaersch/get-root-domaineTLD+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>
Update this before you publish on your own site. 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

  1. Download the JSON file from the Google Drive link.
  2. In GTM, go to Admin → Import Container.
  3. Upload experiment-vary-site-content-with-gtm.json.
  4. Choose Merge (not Overwrite) to preserve your existing tags and triggers.
  5. Update Measurement Stream ID Production and Measurement Stream ID Development to your actual GA4 Measurement IDs.
  6. Update the Randomize Face DOM Ready trigger's page-path filter to match the page where your own transparent-pixel placeholder lives.
  7. Install your own 1×1 transparent placeholder image on that page, and update oldBase in the Randomize Face Image tag plus every entry in the Randomize Face Image Cookie Lookup variable to reference your own placeholder and variant image URLs.
  8. In GA4, confirm chosenFace is registered as a custom event-scoped dimension and user property under Admin → Custom definitions if you want it available for segmenting and Explorations.
  9. Open GTM Preview mode, clear your test cookies, reload the page a few times, and confirm the bucket cookie, the DOM swap, and the experiment GA4 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 chosenFace user 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

Popular posts from this blog

Site Landing & Site Referrer Preservation

GTM Browser Viewport Measurement

UTM & URL Query String 2 Cookies