case study · experimentation · liquid · cloudflare-workers

A/B testing without an app: bucketing, stats, and honest data

The job. Merchants ask for split tests constantly — headline copy, cart messaging, whole PDP layouts — and Shopify has no native theme A/B. The usual answer is a paid app. This store runs three concurrent experiments on a framework built from parts already on the table: Liquid, one shop metafield, localStorage, and the same Worker + KV that run the promo sync. The panel below is its live dashboard.

Bucketing that survives everything. A pre-paint inline script assigns each visitor once (a hash of a random visitor id, persisted in localStorage) and flips a class on <html> before first paint. Both arms of every copy experiment are server-rendered; CSS shows exactly one. No flicker, nothing for Dawn’s Section Rendering re-renders to lose, and visitors without JavaScript simply get the canonical arm. Assignments are sticky by design: anyone may change the traffic split in the ops console, and it only affects new visitors — re-hashing on a new split would silently shuffle existing visitors between arms and poison the test.

The template experiment. The third experiment swaps the entire product-detail template using Shopify’s own alternate-template mechanism (product.exp-b.json, reached via ?view=exp-b — pure template JSON, zero new Liquid). Enforcement is bidirectional: a visitor assigned B who lands on the default PDP is moved onto the alternate template, and a visitor assigned A who opens a shared ?view=exp-b link is moved back — so what renders always equals what was assigned. Internal product links are rewritten at click time, so normal browsing never pays a redirect.

See both arms yourself. Enforcement cuts both ways — it also stops a curious reader from ever seeing the arm they weren’t dealt. So the framework has a preview mode: ?ab_preview=a|b forces the display arm, skips enforcement, and sends no events at all — preview visits never touch the data. Store password is on the demo page, then:

The screenshots above were taken through preview mode on the live store — same visitor, same cart, only the arm forced.

Counting correctly on a database with no atomic increment. Workers KV can’t do count += 1 safely under concurrency. So no request ever touches the aggregate: each beacon batch (sent as text/plain via sendBeacon — CORS-safelisted, no preflight to break) lands in its own unique buffer key, and a single-flight rollup on the Worker’s 5-minute cron is the only writer of the results document. Buffer keys younger than 60 seconds are left for the next cycle, because KV’s list and delete operations are eventually consistent — a residual double-count window exists in theory and is stated here rather than hidden. Exposure means seen, not present in the DOM (the cart drawer’s markup exists on every page — an IntersectionObserver decides), and a conversion only counts for an experiment the session was actually exposed to.

Input is a closed vocabulary. The ingest endpoint validates every event against the running config and silently drops the rest; the console lets anonymous visitors change exactly two things — status and split, both preset choices. Variant copy is fixed: letting strangers rewrite the arms mid-test would end the experiment, so it isn’t a setting.

Statistics you can check. Per arm: conversion rate with Wilson 95% intervals; per experiment: a two-proportion z-test, relative lift, a sample-ratio-mismatch check, and a verdict that refuses to speak below 100 exposures per arm.

The honesty mechanism. A portfolio store has no traffic, so the dashboard’s volume comes from a seeded simulator with known ground truth — labeled simulated everywhere, tallied separately, and never merged with real visitor counts in any view. The truths were chosen to show the full verdict spectrum, because real experimentation mostly isn’t wins: the headline test carries a genuine lift (it reads significant), the PDP template change genuinely hurts (a significant loser — the correct call is roll it back), and the cart phrasing has a true null effect (the dashboard honestly says “not yet”). The framework’s flagship unit test runs the simulator through the aggregation and the stats engine and asserts it recovers the planted lift, catches the planted regression, and does not false-positive the null — the system verifying its own measuring stick.

Stated limits. Randomization is per visitor while exposure/conversion dedup is per session — a standard trade-off, named rather than glossed over. Checkout completion isn’t observable from a theme, so the cart experiment’s metric is begin-checkout. Both caveats would be the first thing a client’s data team asks about; better they read it here first.

← All case studies