cf.completefrontend
My progress
SYSTEM DESIGN · #66

Design an A/B testing system for the frontend

The frontend of an experimentation platform: the screens where teams set up experiments, the code that puts each visitor in a variant and shows it without flicker, the exposure events that say who really saw what, and the results page people use to decide. The hard part is not showing two versions; it is making sure the numbers at the end can be trusted.

The question

We are a large online marketplace on Next.js with about 3 million daily users. Product teams run A/B tests today with a third-party visual editor snippet. Pages flicker, analysts say half the results look wrong, and two teams recently tested on the same checkout button at the same time without knowing it.

Design the frontend side of an in-house experimentation platform: how teams set up an experiment, how the web app assigns visitors and shows the variant, how we record who saw what, and the results page product managers use to make a decision.

Real-life examples: Optimizely Web and Feature Experimentation, Statsig experiments, Eppo, GrowthBook, LaunchDarkly Experimentation, In-house platforms at Microsoft, Netflix, and Booking.com

Requirements, and what makes experiments hard

An A/B test (also called an experiment) shows two or more versions of something to randomly chosen groups of users and measures which version does better. The current version is the control; each new version is a treatment or variant. An experimentation platform is everything that makes this routine: a place to set up tests, code that puts each user in a group, event logging, a statistics engine, and a results page.

This guide builds on other guides and does not repeat them. The feature-flag-sdk guide covers how a flag is evaluated in the browser, how a user is hashed into a stable bucket, how values reach the first frame without a flash (bootstrapping), and the basics of exposure events. The analytics-sdk guide covers how events get from the browser to the server: batching, sendBeacon, retries. The cookie-consent guide covers how consent is collected and read.

The ab-testing lesson covers the basic idea of a test and a p-value. Here we focus on what goes wrong when a company runs 50 experiments at once and needs every one of them to be right.

That focus matters because experiments fail silently. A broken button shows up in error tracking within minutes. A broken experiment shows up as a confident, wrong decision: the team ships a variant that "won by 3 percent" when the real effect was zero, or kills a good idea because a slow variant lost users before they were counted. Nobody notices, because the dashboard still shows a nice chart.

Questions to ask

QuestionAnswer we assumeWhat it changes
Who sets up experiments?About 30 product teams. Product managers and engineers create them; about 10 data scientists review the risky ones.The setup screens must guide non-experts, with review steps and safe defaults.
How many run at once?40 to 60, with about 15 new ones each week.Experiments will overlap on the same pages, so we need layers to keep them apart.
What do experiments change?Mostly UI: copy, layout, ranking, new features. Some change speed, such as a lighter product page.Where the variant is applied (server, edge, or browser) and how we measure speed per variant.
How are pages rendered?Next.js with server rendering; marketing pages are cached on a CDN.We can pick the variant before HTML is sent, and must handle cached pages.
Do users browse logged out?Yes. About 70 percent of sessions start logged out, and many log in at checkout.The unit of randomization and identity stitching become central.
Where are results computed?In our data warehouse, from exposure and conversion events, once per hour.The results page reads precomputed numbers; it does not run statistics in the browser.
Which regions?EU and US.Consent rules decide what we may store and log before the user agrees.

Functional requirements

  • Design an experiment: hypothesis, variants, a primary metric, guardrail metrics, the unit of randomization, targeting, traffic allocation, a layer, and a planned sample size.
  • Assign every visitor to a variant deterministically, the same way on the server, at the edge, and in the browser, and keep that assignment stable when allocation changes or the user logs in.
  • Show the variant on the first frame, with no flicker and no layout shift.
  • Log an exposure when the user actually sees the variant, once per user per experiment.
  • Analyze: join conversions to exposures, check data quality (sample ratio mismatch), and compute effects with confidence intervals.
  • Show results that a product manager can read correctly: primary metric, guardrails, segments, and a clear decision.
  • QA and debug: force a variant, share preview links, and see every assignment on the page.
  • Govern: review before launch, detect collisions, stop and clean up finished experiments.

Non-functional requirements

RequirementTargetWhy this number
Assignment costUnder 1 ms, in memory, no network callDozens of assignments can happen during one render.
Layout shift caused by experiments0 added CLS (Cumulative Layout Shift, the Core Web Vital for content jumping)Any shift is visible to users and biases the metrics of the variant that causes it.
LCP (Largest Contentful Paint) cost of experimentationUnder 50 ms at the 75th percentileA slow testing system makes every variant slower and hides real effects.
Exposure eventsAbout 25 million a day after deduplication (3 million users, about 8 experiments each)Sizes the pipeline and tells us dedupe must happen on the client first.
Results page loadUnder 2 seconds for 30 metrics and 10 segmentsTeams open it daily; it must feel like a dashboard, not a report job.
CorrectnessSRM checked on every experiment, every refreshBad data must block a decision, not just add a footnote.
Failure behaviorIf anything fails, users get control and are not countedA broken experiment must never break the page or pollute the data.

Out of scope, and why

  • Event transport: batching, retries, and offline queues belong to the analytics-sdk guide. We define which events to send and when.
  • Flag evaluation internals: rule matching and the SDK's React hooks belong to the feature-flag-sdk guide. We use them.
  • The warehouse statistics code: we explain what it computes and how the frontend shows it, not the SQL.
  • Multi-armed bandits, which shift traffic toward the winner during the test. They answer a different question (earn the most now) than an A/B test (learn the true effect), and most platforms keep them separate.
  • Native mobile apps: the ideas carry over, but release cycles and caching are different.

Check your understanding · Exercise 1

Which answers from the interviewer would change the design the most? Select all that apply.

What makes experiments hard to get right

Showing version B to half the users is one line of code. The hard part is that an experiment is a measurement, and every step between "a product manager has an idea" and "we ship it" can bend the measurement without anyone noticing. Think of it as a chain: the result is only as good as its weakest link.

The experiment chain as seven links, top to bottom, each with how it quietly breaks. Design: the metric is picked after seeing the data. Assign: a user moves between groups. Render: the variant flickers or loads late. Expose: users are counted before seeing the change. Convert: events are lost or not joined. Analyze: peeking, and sample ratio mismatch ignored. Decide: a p-value is read as a sure win.linkhow it quietly breaks1 designmetric picked after seeing the data2 assignuser moves between groups3 rendervariant flickers or loads late4 exposecounted before seeing the change5 convertevents lost or not joined6 analyzepeeking, SRM ignored7 decidep-value read as a sure win
A result is only as good as its weakest link, and none of these failures throws an error. The rest of the guide takes the links in this order.
  • Correctness is the product. For most features, a small bug is a small problem. Here a small bug, such as logging exposure 200 ms too early, can flip the decision on a feature worth millions. The platform's job is to make wrong results hard to produce and easy to detect.
  • Randomization must survive the real web. Users clear cookies, switch devices, log in halfway through a visit, open links in new tabs, and arrive through cached pages. Each of these can move a person from one group to another or count them twice.
  • The test can change what it measures. If the variant is applied by a script that runs after the page paints, the variant also flickers and loads later. The test then measures "new design plus a flicker", not the new design.
  • Experiments overlap. With 50 tests live, the same user is in about 8 at once. Two tests on the same button can interact, and nobody sees it unless the platform tracks where each test runs.
  • The readers are not statisticians. A product manager looks at the results every morning and wants to stop as soon as it turns green. Doing that turns a 5 percent false positive rate into 20 percent or more. The results page has to protect people from that habit.
  • Experiments end. Each finished test leaves dead branches in the code. At 15 launches a week, that is hundreds of stale if statements a year unless cleanup is part of the process.

Think first

A new product page design is tested with a visual editor. Its changes appear about 400 ms after the original page paints. The test shows the new design converting 1.5 percent worse. What did the test really measure?

So the guide follows the chain. First the experiment's design in the platform, then how many experiments share traffic, then assignment, then how the variant is shown, then exposure logging, then the checks and statistics that decide whether the result can be trusted, and finally the results page, QA, and governance that keep it all honest over time.

Designing an experiment: hypothesis, metrics, unit, allocation, and layers

Most broken experiments are broken before the first user arrives. The team picks five metrics and decides later which one counts, randomizes per page view when the change lasts a whole visit, or launches with no idea how long the test must run. The setup screen is where the platform can prevent all of this, so we design it as a guided form, not a blank settings page.

Start with a hypothesis

The form opens with one required field shaped as a sentence: "Because we saw evidence, we believe that change for audience will move metric by at least amount." For example: "Because 38 percent of mobile users abandon on the shipping step, we believe that showing delivery dates on the product page for mobile users will raise checkout completion by at least 1 percent." Writing it down before launch stops the most common trap: running the test first and then searching the metrics for a story.

Primary, secondary, and guardrail metrics

KindHow manyRoleExample
PrimaryExactly 1Decides ship or not. The sample size is planned for it.Checkout completion per visitor
SecondaryA fewExplains why the primary moved. Never decides alone.Add-to-cart rate, delivery-info clicks
Guardrail3 to 8, many set by defaultMust not get worse. A significant drop blocks shipping even if the primary wins.Revenue per visitor, LCP at p75, JavaScript error rate, support contacts

Metrics are picked from a shared metric catalog, not typed as free text. The catalog holds one definition of each metric (which event, which filter, per user or per session), owned by the data team. If two teams each write their own "conversion", their results can't be compared and one of them is usually wrong. The platform also adds company guardrails automatically, such as error rate and page speed, so a team cannot forget them.

The unit of randomization

The unit of randomization is the thing we flip a coin for: a user, a device, a session, or a whole company account. Everything that shares a unit sees the same variant. Pick it badly and the same person sees both versions, which mixes the groups and shrinks every measured effect toward zero.

Decision What should be the default unit of randomization?

Logged-in user id

Hash the account's user id.

Works on every device the person uses.

Pros

  • Same variant on phone and laptop.
  • Matches how revenue and retention are counted.

Cons

  • Logged-out visitors (70 percent of our sessions) have no id, so they can't be in the test at all.
  • Anything shown before login can't use it.

Device id (a first-party cookie)

The server sets a random id in a cookie, such as cf_did, on the first visit with analytics consent.

Hash that id for every experiment.

Pros

  • Covers logged-out traffic from the first page view.
  • Available on the server, so the first HTML can be the variant.

Cons

  • One person on two devices may see two variants.
  • Cleared cookies and private windows create new ids, which slightly inflates user counts.

Session

A new coin flip for every visit.

Pros

  • Many more units, so tests reach significance faster.
  • Fine for changes that only matter inside one visit, such as search ranking.

Cons

  • Returning users flip between variants, which is confusing for anything visible.
  • Effects that build over days, like retention, can't be measured.

Account (company or household)

Hash the organization id, so a whole team gets one variant.

Pros

  • Required when users share the feature: a shared document, a team billing page.
  • Avoids one coworker seeing a feature another can't.

Cons

  • Far fewer units, so tests need much longer.
  • Large accounts can dominate the results.

What we pick: Default to the device id for anything a logged-out visitor can see, and to the user id for surfaces that require login. Use account for collaborative features and session only when the change cannot outlive one visit. The setup form makes the choice explicit and shows a warning when the unit does not match where the change appears, for example a user-id test on the logged-out home page.

The hard case is the visitor who starts logged out and logs in at checkout. If the product page test was randomized by device id, it should stay on device id after login; switching to the user id mid-visit can move the person into the other variant on the very page where they convert. The figure shows the rule: an experiment keeps the unit it started with for its whole life, and identity stitching (covered in the assignment chapter) only links ids for analysis.

One shopper at three moments. On the phone, logged out, the only id is device d-81: a device-unit test shows variant B, and a user-unit test does not include them. On the phone after logging in, the ids are d-81 and user u-42: the device test still shows B, and the user test now assigns A, starting on the next page. On a laptop the next day, the ids are d-93 and u-42: the device test hashes the new device and shows A, a cross-device mix, while the user test still shows A. Notes: the device test keeps d-81 after login so nothing swaps mid-checkout, and stitching lets analysis measure the share of mixed people.phone, logged outd-81device test: Buser test: nonephone, logged ind-81 + u-42device test: Buser test: Alaptop, next dayd-93 + u-42device test: Auser test: Adevice test keeps d-81 after login: no swap mid-checkoutlaptop d-93 may land in A: stitching measures the mix
Each experiment keeps the unit it launched with. Login never moves a device test, so checkout doesn't change under the user; the cost is that a new device can land in the other arm, which stitching measures.

Allocation and traffic splits

Two numbers decide who is in the test. Traffic allocation is the share of eligible units that enter the experiment at all, such as 20 percent. The split divides those units between variants, such as 50/50.

Equal splits give the most statistical power for a given number of users. A 90/10 split looks safer, but the small arm makes the test need about 2.8 times as many users for the same precision. Use a small allocation for safety instead, and keep the split equal.

json
{
  "key": "pdp-delivery-dates",
  "hypothesis": "Showing delivery dates on mobile PDP raises checkout completion by 1%",
  "unit": "device_id",
  "layer": "pdp-main",
  "iteration": 3,
  "allocation": 0.2,
  "variants": [
    { "key": "control", "weight": 0.5 },
    { "key": "dates", "weight": 0.5 }
  ],
  "targeting": { "device": ["mobile"] },
  "exposeOn": "visible",
  "primaryMetric": "checkout_completion",
  "guardrails": ["revenue_per_visitor", "lcp_p75", "js_error_rate"],
  "plannedSampleSize": 307200,
  "salt": "pdp-delivery-dates:3f9a"
}
What the setup form saves: one experiment, in the config the SDK and the stats engine both read.

Check your understanding · Exercise 2

A team tests a new shared shopping list that family members edit together. Which statements are right? Select all that apply.

Running 50 experiments at once: layers, exclusion, and holdouts

Each experiment hashes the unit id with its own salt, so assignments in different experiments are independent: being in the treatment of one test tells you nothing about your group in another. For most pairs of tests this is exactly what we want. A test on search ranking and a test on the footer can share every user, and each one's random split balances the other's effect out.

It breaks when two tests touch the same thing. Team A tests a green "Buy now" button; team B tests the button text "Add to bag". A user in both treatments sees a combination nobody designed.

Worse, team C tests removing the button from the product page entirely, and team B's users in C's treatment never see B's change, so B's data is quietly diluted. These interactions are rare across a whole site, but they cluster on hot pages like the product page and checkout, which is where the important tests run.

Decision How should concurrent experiments share traffic?

Full overlap

Every experiment hashes independently over all traffic.

Pros

  • Maximum traffic for every test, so tests finish fastest.
  • Nothing to configure.

Cons

  • Tests on the same element interact with no warning.
  • One test can make another's change invisible.

Full isolation

Each user is in at most one experiment anywhere on the site.

Pros

  • No interactions at all.

Cons

  • With 50 tests, each gets about 2 percent of users, so tests run 25 times longer.
  • Throws away the independence that makes overlap safe for unrelated tests.

Layers (exclusive within, overlapping across)

Experiments are grouped into layers, usually one per surface: pdp-main, checkout, search-ranking.

Within a layer, each experiment owns a slice of the buckets, so a user is in at most one test per layer.

Across layers, assignment is independent.

Pros

  • Tests that could clash never share a user.
  • Unrelated tests still share all traffic.
  • The layer is an explicit, reviewable choice.

Cons

  • Teams must pick the right layer; a wrong pick reintroduces the clash.
  • A busy layer is a queue: tests wait for free buckets.

What we pick: Use layers. Google described this approach in its 2010 overlapping-experiments paper (Tang and colleagues, KDD 2010), which adds domains, slices of traffic that can hold their own set of layers. Most commercial platforms offer a version of it: Optimizely calls it exclusion groups, Statsig calls them layers. The setup form suggests a layer from the pages and components the test touches, and the collision check in the governance section warns when two tests in different layers still touch the same component.

A holdout and three layers. First, a holdout is carved out of all traffic before any layer assigns; everyone else is eligible for every layer. Each layer splits 10,000 buckets between its experiments. pdp-main: experiment A owns buckets 0 to 2,999, experiment B owns 3,000 to 5,999, and 6,000 to 9,999 are free. checkout: experiment C owns 0 to 4,999, D owns 5,000 to 7,999, and the rest is free. search: search-v3 owns all 10,000. Device d-81, hashed with each layer's own salt, lands in B, C, and search-v3: one experiment per layer, never A and B together.all trafficholdouteligible for every layerbucket 09,999pdp-mainexp Aexp Bfreecheckoutexp Cexp Dfreesearchsearch-v3d-81 lands in B, C, and search-v3never in A and B at once
Inside a layer, experiments are mutually exclusive. Across layers, a different salt reshuffles everyone, so the same device can be in one test per layer. The holdout comes first, so it is the same people in every layer.

Two hashes: one for the layer, one for the variant

Assignment inside a layer takes two steps. The first hash, salted with the layer's id, places the unit in one of 10,000 layer buckets; each experiment owns a range of them. The second hash, salted with the experiment's own salt, picks the variant.

Using a separate salt keeps the variant independent of the slot. Suppose we took the variant from the user's position inside the range instead: first half control, second half treatment. Widening the range from 1,000 to 2,000 buckets would then move every user in slots 500 to 999 from treatment to control in the middle of the test.

ts
type Range = { experiment: string; from: number; to: number };

function assignInLayer(unitId: string, layer: Layer): Assignment | null {
  const slot = hashToBucket(`${layer.salt}:${unitId}`); // 0..9999
  const range = layer.ranges.find((r) => slot >= r.from && slot < r.to);
  if (!range) return null; // free buckets: in no experiment of this layer

  const exp = layer.experiments[range.experiment];
  if (!matchesTargeting(exp, unitId)) return null;

  const pick = hashToBucket(`${exp.salt}:${unitId}`) / 10000; // 0..1
  let edge = 0;
  for (const v of exp.variants) {
    edge += v.weight;
    if (pick < edge) return { experiment: exp.key, variant: v.key };
  }
  return null;
}
Layer assignment. hashToBucket is the same deterministic hash the flag SDK uses (for example MurmurHash3 of the salted id, modulo 10,000).

Holdouts: measuring everything we shipped

Each experiment measures one change over two to four weeks. Leadership asks a different question: did the 40 changes we shipped this quarter add up to the 12 percent gain the individual tests claim? Usually they don't, because of novelty effects, overlap between wins, and a few false positives.

A holdout answers it: a small slice of users, often 1 to 5 percent, is kept out of every experiment and every launch from a set of teams for a quarter. Comparing them with everyone else measures the real cumulative effect.

Holdouts have real costs, so the platform treats them as a first-class object with an owner and an end date. Held-out users miss improvements for months. Engineers must keep old code paths alive until the holdout ends. And the holdout must be carved out first, with its own salt, before any layer assigns, so that it is the same users across every layer.

Check your understanding · Exercise 3

Team A tests the checkout button color and team B tests the checkout button label. Both are in the checkout layer. What follows? Select all that apply.

Assignment: salts, sticky buckets, and identity stitching

The feature-flag-sdk guide covers the core: the SDK hashes a salted unit id into one of 10,000 buckets, so the same id always lands in the same bucket on the server, at the edge, and in the browser, with no network call. This chapter is about what experiments add on top: why the salt must change per experiment, what happens when someone edits a running test, and how one person with three ids stays one person.

A salt per experiment, and a new one per restart

Suppose every experiment hashed the bare device id. Device d-81 lands in bucket 3,120 for every test, and with 50/50 splits buckets 0 to 4,999 are always treatment. Half of our users would see every new idea and the other half none.

After a year the treatment group is a different population: used to change, maybe tired of it. That leftover influence is called carryover, and it biases every later test. A unique salt per experiment, such as pdp-delivery-dates:3f9a, reshuffles users for each test.

The same logic applies when a test is restarted after a bug. If the first run crashed the variant for two days, the users who saw the crash are still in the treatment group and still annoyed. Restarting with a new salt reshuffles everyone, so the damage is spread evenly over both groups. The platform does this automatically: "Restart" creates a new iteration with a fresh salt, and analysis only reads data from the current iteration.

Keeping users in place when the allocation changes

Teams like to start at 10 percent and ramp up to 50 percent once nothing breaks. If an experiment's bucket range only grows, ramping is safe: extending it from buckets 0 to 999 to buckets 0 to 4,999 only adds users, and everyone already in stays in their variant, because the variant comes from the second, independent hash. Two edits are not safe: changing the split (50/50 to 80/20) and ramping down. Both move users who were already exposed.

Two edits to a running experiment, shown on ten bucket groups of 1,000 buckets each. Ramp up: at 10 percent only the first group is in, with variant B. At 50 percent the first five groups are in: the first keeps B, and four new groups join with A, B, B, A. Nobody already in changes. Split change: at 50/50 the ten groups show B, A, B, B, A, A, B, A, A, B, the first five matching the ramp above. After changing to 80/20 they show A, A, B, A, A, A, B, A, A, A: three groups that were in B, the first, fourth, and tenth, have moved to A.ramp up: users only joinsplit change: exposed users move10%B50%BABBA50/50BABBAABAAB80/20AABAAABAAA
Growing the range only adds people, because the variant comes from a separate hash. Changing the split re-cuts that second hash, so already-exposed users switch arms; the platform makes that a new iteration.

Decision How do we keep assignments stable when a running experiment is edited?

Pure hashing, and block unsafe edits

Allow only monotonic ramp-ups on a running experiment.

Any split change or ramp-down starts a new iteration with a new salt.

Pros

  • No storage, no lookups; server, edge, and browser always agree.
  • Every iteration's data is clean.

Cons

  • A new iteration restarts the clock on sample size.
  • Targeting changes (a user's country attribute changes) can still move a user out.

Server-side assignment store

The first assignment for each unit is written to a database.

Later evaluations read it before hashing.

Pros

  • Assignments survive any edit.

Cons

  • A lookup on the critical path of every request.
  • Billions of rows over time; the edge can't easily reach it.

Client-side sticky record

After the first exposure, store experiment:variant in a first-party cookie or localStorage.

Evaluation prefers the stored variant over a fresh hash.

Pros

  • Cheap and fast.
  • Handles targeting drift for a single device.

Cons

  • Only per device and only until storage is cleared.
  • Server and browser can disagree if the cookie is missing on one side.

What we pick: Use pure hashing with blocked unsafe edits as the rule, because it keeps every evaluation in agreement and every dataset clean. Add an optional sticky record in a cookie only for experiments where targeting attributes can change mid-test; the cookie is readable by the server, so the first HTML and the browser still agree. GrowthBook (sticky bucketing) and Optimizely (its user profile service) offer similar opt-in persistence on top of deterministic hashing, so storage is an addition for special cases, not the default.

Identity stitching: one person, several ids

A shopper browses on their phone logged out (device d-81), logs in at checkout (user u-42), and the next day buys on a laptop (device d-93). Identity stitching records that these ids belong to one person, so that analysis can follow them. The client's job is small but exact: when login succeeds, send one identify event linking the device id to the user id. The warehouse builds the person graph from those events.

A sequence diagram of logging in during a visit, with columns user, page, auth API, exp client, and events. The user submits the login form. The page sends POST /login. The auth API replies 200 with user u-42. The page calls setUser with u-42 on the experiment client. The client sends one identify event linking device d-81 to u-42. The client tells the page that user-unit tests apply from the next page, while device-unit tests are unchanged. Failure path: if login fails, the auth API replies 401, nothing is sent, and the ids stay as they were.userpageauth APIexp clienteventssubmit loginPOST /login200: u-42setUser()identifyfrom next pageif login fails401no event
Login produces exactly one identify event and no change on the current page. Device-unit tests ignore the login; user-unit tests begin on the next navigation, so nothing swaps under the shopper.
  • Device-unit experiments ignore the login. They keep hashing d-81, so the checkout the user is on does not change variant at the moment they log in.
  • User-unit experiments start at login. They were not eligible before. If one applies to the current page, its new variant takes effect on the next navigation, not mid-page, so nothing swaps under the user.
  • Cross-device mixing is measured, not hidden. With stitching, analysis can see that person u-42 was control on d-81 and treatment on d-93. The results page reports this share (often a few percent) and warns if it grows large.
  • Logout keeps the device id. Clearing it on logout would put the same device in new buckets and double-count it.

Check your understanding · Exercise 4

A running 50/50 experiment at 20 percent allocation has a bug in the variant for two days. Which actions keep the data trustworthy? Select all that apply.

Where the variant is applied: browser, server, or edge

The current setup is the classic one: a vendor snippet in the page head downloads a list of changes made in a visual editor ("change this heading to X, hide that banner") and applies them to the DOM after the page arrives. It needs no engineers, which is why marketing teams love it. It also has two failure modes that bias results, and the fix for one makes the other worse.

  • Flicker. If the snippet loads asynchronously, the browser paints the original page first, then the snippet rewrites it. Users see the control for a moment and then the variant. If the change adds or resizes something above the fold, content jumps, which adds layout shift (CLS) to the variant only.
  • Hidden pages. To stop flicker, vendors either load the testing script synchronously in the head, which blocks rendering until it has run (Optimizely Web recommends this), or ship an anti-flicker snippet: a tiny inline script and style that hides the page (opacity: 0 or visibility: hidden) until the testing script has run, or until a timeout fires. Either way the page is blank for that time, for every user in every group, including users in no experiment at all.
Three ways to apply a variant on one time axis from 0 to 2.5 seconds, using estimated numbers for a mid-range phone on 4G. SSR variant: loading until 1.0 second, then the variant is painted directly; LCP about 1.6 seconds and no layout shift. Async visual editor: loading until 1.0 second, then the control flashes until 1.6 seconds, then the variant appears and shifts the layout; LCP about 1.7 seconds and CLS about 0.1. Anti-flicker snippet: the page is hidden until 1.9 seconds, then the variant appears; LCP about 2.0 seconds, and every visitor pays it.SSR variantloadingvariantLCP ~1.6 s, no shiftasync editorloadingcontrolvariant + shiftLCP ~1.7 s, CLS ~0.1anti-flickerhiddenvariantLCP ~2.0 s, for everyone0 s0.5 s1 s1.5 s2 s2.5 s
Estimates, not measurements: the shape is what matters. Server rendering shows the right thing first. The async editor shows the wrong thing first and then moves it. Anti-flicker hiding trades the flash for a blank page whose reveal time sets a floor under LCP.

The figure uses estimates for a mid-range phone on a 4G connection; real numbers depend on the vendor, the device, and how many changes are configured. A testing script of 40 to 100 KB that must be fetched from a third-party origin, parsed, and run often takes 200 to 600 ms before changes apply.

With anti-flicker hiding, nothing can paint until that script has run, so the reveal time becomes a floor under first contentful paint (FCP) and LCP for every visitor. In the figure, content that would show at 1.6 seconds shows at about 2.0. On a slower phone the same delay can push a page past the 2.5 second "good" LCP threshold.

Without hiding, the cost moves to layout shift. CLS scores each shift as the share of the viewport that moved times the distance it moved, as a share of the viewport's height. An above-the-fold change that pushes most of a 700-pixel-tall viewport down by 80 pixels scores about 0.9 x 0.11, or 0.1, on every variant view: the edge of the "good" range on its own.

Decision Where should the variant be applied?

Client-side DOM changes (visual editor)

A script edits the DOM after the page loads, using changes saved in the editor.

Pros

  • No code changes or deploys; non-engineers can launch tests.
  • Works on any page, even ones we don't own.

Cons

  • Flicker and CLS, or a hidden page and worse LCP.
  • Fragile selectors break when the page markup changes.
  • Fights React: a re-render can undo or duplicate the injected change.

Code variants rendered on the server

Engineers write both versions as components behind an experiment hook.

The server assigns, renders the right version into the HTML, and passes the assignments to the client for hydration.

Pros

  • Correct on the first frame: no flicker, no added CLS, no hiding.
  • Variants are real code: typed, tested, reviewed.

Cons

  • Needs a deploy for each test.
  • Pages that are cached on a CDN serve one HTML to everyone.

Edge-side variants

A function at the CDN edge reads the device id cookie, assigns, and fetches or serves a variant-specific cached copy of the page.

Pros

  • Cached pages stay fast and still get a correct first frame.
  • Assignment happens before any HTML is sent.

Cons

  • The cache stores one copy per combination of variants.
  • Assignment logic and config must run at the edge too, in the same way.

What we pick: Render code variants on the server for dynamic pages and use edge-side variants for CDN-cached pages. Keep a visual editor for its useful part, the editing, but change what it outputs: it saves content overrides (copy, images, hide or show) for named slots that our components expose, and the server renders them. The vendor snippet and its anti-flicker hiding are removed. This is the same principle as bootstrapping flags without a flash in the feature-flag-sdk guide: decide before the first byte of HTML.

Edge-side variants and cache keys

A cached product page has one URL and one cached copy. If the edge simply varied on the Cookie header, every visitor's unique cookie string would create its own cache entry and the hit rate would collapse to near zero. Instead, the edge function computes a short variant key from only the experiments that apply to this path, such as x7.b-x9.a, and uses it as part of the cache key. The cdn-caching lesson covers cache keys in general.

Edge-side variants. Three visitors with device ids d-81, d-93, and d-12 send requests to an edge function. The edge function hashes each id for the two experiments on this path, x7 and x9, and builds a short variant key. The CDN cache holds one copy per key: x7.a-x9.a, x7.a-x9.b, x7.b-x9.a, and x7.b-x9.b. d-93 gets x7.a-x9.a, d-81 gets x7.b-x9.a, and d-12 gets x7.b-x9.b. On a miss, the origin Next.js server renders that key once.d-81d-93d-12edge functionvariant keyCDN cachex7.a-x9.ax7.a-x9.bx7.b-x9.ax7.b-x9.boriginrenders a key on a miss
Two experiments with two variants each make four cached copies, whatever the number of visitors. Varying on the whole Cookie header instead would make one copy per visitor and the hit rate would fall to almost nothing.
ts
export async function handle(req: Request): Promise<Response> {
  const url = new URL(req.url);
  const known = readCookie(req, "cf_did");
  // No analytics consent: no id, no assignment, everyone gets control.
  const deviceId = hasAnalyticsConsent(req) ? (known ?? crypto.randomUUID()) : null;

  const exps = deviceId ? config.experimentsFor(url.pathname) : []; // config pushed to the edge
  const key = exps
    .map((e) => `${e.id}.${assign(e, deviceId!) ?? "off"}`)
    .sort()
    .join("-");

  const target = new URL(url.pathname + url.search, ORIGIN);
  target.searchParams.set("__v", key || "base"); // part of the cache key
  const cached = await fetch(target, { headers: req.headers });

  const res = new Response(cached.body, cached);
  res.headers.set("Cache-Control", "private, no-store"); // browser copy is per user
  if (deviceId && !known) res.headers.append("Set-Cookie", deviceCookie(deviceId));
  return res;
}
An edge handler in plain Fetch API terms. The origin renders each variant key once; the CDN caches it as its own URL.

Two limits keep this safe. First, cardinality: three experiments with two variants each on one path means up to 8 cached copies, and five means 32. The platform caps cached-page experiments per path (we use 3), and the layer system helps, since experiments in one layer are exclusive and add copies instead of multiplying them.

Second, the edge only assigns. The page still carries the assignments to the browser SDK so hydration matches, and exposure is still logged by the browser when the variant is seen, as the next chapter explains.

Check your understanding · Exercise 5

A marketing team wants to keep the vendor visual editor with its anti-flicker snippet, set to a 3-second timeout. Which statements are true? Select all that apply.

Exposure logging: count people when they see the change

Being assigned to a variant and being exposed to it are different events. Assignment is a calculation: the server or SDK works out which variant this unit would get. Exposure is a fact: the unit was shown the experience that differs between variants. The analysis should include exactly the exposed units, from both groups, counted from the moment of exposure.

Why logging at assignment wastes most of the test

The delivery dates test changes a block that sits below the fold on mobile product pages. The feature-flag-sdk guide explains why counting people who never saw a change dilutes the result; here we size the damage. If we log exposure when the server assigns, every mobile product page view counts. Say 1,000,000 devices are assigned and only 120,000 scroll far enough to see the block.

If the change lifts conversion by 2 percent for people who see it, it lifts it by only about 0.24 percent across all assigned devices, because 88 percent of them had the same experience in both groups. Required sample size grows with one over the effect squared, so the diluted test needs about 70 times as many counted units. Only 12 of every 100 visitors would be counted under visibility logging anyway, so in traffic terms that is about 8 times more: a 2-week test becomes a 4-month test.

Decision When should the client log an exposure?

At assignment

Log every experiment the SDK evaluates at page load or on the server.

Pros

  • Simple, and it can never miss an exposure.

Cons

  • Counts many units who never saw the change, which dilutes the effect, often several times over.
  • Evaluating all flags at boot logs experiments for pages the user never opens.

At render

Log when a component reads the experiment, which is what the flag SDK's hook does by default.

Pros

  • Accurate for changes visible as soon as the component mounts.
  • No extra code for teams.

Cons

  • Still counts below-the-fold blocks, closed tabs, and unopened menus that were rendered but not seen.

At visibility

Wrap the changed area in an exposure boundary that uses IntersectionObserver.

Log when at least half of it has been in the viewport for a short time, such as 500 ms.

Pros

  • Counts only real views.
  • Works for below-the-fold blocks, carousels, and modals.

Cons

  • Needs a boundary in both variants at the same place.
  • Slightly more code and a little more client work.

What we pick: Default to render-time exposure, which the flag SDK already does, and require visibility-based exposure for changes that are below the fold or inside something that must be opened. The experiment config stores exposeOn: "render" or exposeOn: "visible", and the setup form suggests the second when the component is not in the first viewport. Whatever the trigger, both groups use the same one.

tsx
export function ExposureBoundary({ experiment, children }: Props) {
  const ref = useRef<HTMLDivElement>(null);
  const { variant, logExposure } = useExperiment(experiment);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    let timer = 0;
    const io = new IntersectionObserver(([entry]) => {
      clearTimeout(timer);
      if (entry.isIntersecting) timer = window.setTimeout(() => {
        logExposure(); // deduped inside the client
        io.disconnect();
      }, 500);
    }, { threshold: 0.5 });
    io.observe(el);
    return () => { clearTimeout(timer); io.disconnect(); };
  }, [logExposure]);

  return <div ref={ref} data-experiment={experiment} data-variant={variant}>{children}</div>;
}
An exposure boundary. Both arms render it at the same spot; only its children differ.

One source of truth, deduped twice

Assignment must be computed once per page and shared. The server or edge assigns, writes the assignments into the HTML bootstrap, and the browser's experiment client reads them from there instead of hashing again. Every exposure carries that exact assignment: experiment key, iteration, variant, unit type, unit id, and where it was assigned (server, edge, or client).

The warehouse never recomputes variants from ids. It can, however, recompute them as a nightly check: a mismatch rate above zero means a bug in some SDK.

Deduplication happens in two places. The client keeps a set of keys like pdp-delivery-dates:3:d-81 in memory and in sessionStorage, so re-renders and route changes send nothing new. That drops roughly 200 million raw exposure triggers a day to about 25 million events.

The warehouse then keeps the first exposure per unit, experiment, and iteration, because tabs, devices, and retries can still send duplicates. Events travel through the analytics SDK's queue, so batching, sendBeacon on pagehide, and retries are already handled.

A sequence diagram of an exposure, with columns user, boundary, exp client, analytics, and collector. The user scrolls the changed block into view. After it has been half visible for 500 ms, the boundary calls logExposure on the experiment client. The client checks whether this key was already sent, and it was not, so it sends an exposure event to the analytics SDK. The analytics SDK sends a batch POST to the collector, which replies 204. Failure path one: the tab is closed before the batch is flushed, so the analytics SDK sends the queued events with sendBeacon on pagehide. Failure path two: the user scrolls back and the boundary calls logExposure again; the key was already sent, so it is dropped.userboundaryexp clientanalyticscollectorscrolls into viewafter 500 mslogExposure()new key? yesexposure eventbatch POST204tab closedsendBeaconscrolls backlogExposure()dropped
The client dedupes before anything leaves the page, and the analytics SDK owns delivery. A tab closed mid-batch is still counted through sendBeacon; a second view of the same block adds nothing.

Check your understanding · Exercise 6

A test changes the content of the "Filters" drawer, which users must tap to open. Which logging plans are correct? Select all that apply.

Trusting the data: sample ratio mismatch

Before reading any metric, check the one number we know in advance: how many units each group should have. A 50/50 test should have about equal exposure counts. If it doesn't, something removed or added units in one group in a way that probably relates to the outcome, and every metric is suspect.

This is a sample ratio mismatch, or SRM. It is one of the most common reasons experiments turn out invalid. Microsoft researchers (Fabijan and colleagues, KDD 2019) reported SRM in about 6 percent of Microsoft's A/B tests, and LinkedIn once saw it in about 10 percent of its triggered analyses.

The check: a chi-square test on counts

Random assignment never gives exactly 50/50, so we need to know how far off is too far. A chi-square goodness-of-fit test compares observed counts with expected counts. Take 50,000 exposed units in control and 48,500 in treatment, so the expected count is 49,250 each.

The statistic sums (observed - expected)^2 / expected over both groups: about 11.4 + 11.4 = 22.8. With two groups (one degree of freedom) that gives a p-value near 0.000002. A split that looks harmless, 49.2 percent instead of 50, is almost impossible by chance at this size.

ts
function srmCheck(counts: number[], weights: number[]) {
  const total = counts.reduce((a, b) => a + b, 0);
  const chi2 = counts.reduce((sum, observed, i) => {
    const expected = total * weights[i];
    return sum + (observed - expected) ** 2 / expected;
  }, 0);
  const df = counts.length - 1;
  const p = df === 1 ? erfc(Math.sqrt(chi2 / 2)) : chiSquareSurvival(chi2, df);
  return { chi2, p, mismatch: p < 0.001 };
}
SRM check for any number of variants. For two groups (one degree of freedom) the p-value has a closed form; more groups use a chi-square CDF from a stats library.

Platforms use a strict threshold, because the check runs on every experiment at every refresh; at 0.05, one healthy experiment in twenty would raise a false alarm. Microsoft's platform uses p below 0.0005, GrowthBook defaults to 0.001, and we use 0.001.

When the check fires, the results page hides the decision and shows a blocking banner. That is a deliberate product choice: an SRM warning in small grey text gets ignored.

What causes SRM in a frontend

CauseWhat happensFix
Slow variantTreatment loads 400 ms later. Some users leave before the exposure fires, so treatment has fewer and more patient users.Log exposure as early as the change is visible, and fix the speed or measure it as a guardrail.
Redirect testTreatment redirects to /pdp-v2. The extra round trip loses some users, and exposure on the new page never fires for them.Prefer a server rewrite to a redirect. If a redirect is needed, log exposure before redirecting.
Crash in one armThe variant throws during render; the error boundary shows a fallback and the exposure effect never runs.Log exposure from the boundary even on fallback, and add JavaScript errors per arm as a guardrail.
BotsBots that run JavaScript differently in one arm, or a bot filter that one variant's markup trips.Apply the same bot filter to both arms before analysis, and check SRM on human traffic only.
CachingThe CDN serves a cached control page to some treatment users, who log a control exposure.Variant-aware cache keys, as in the edge section.
Asymmetric triggersControl logs on page load, treatment on visibility.The same trigger in both arms (counterfactual logging).

Try the first two causes in the lab. Its losses are realistic estimates, not measurements: about 1 percent of users leave before exposure for each extra 100 ms the variant needs, and a redirect loses about 3 percent. The users who leave would have converted at 1 percent, against an average of 4 percent, since impatient visitors buy less. Watch how a small leak becomes a clear SRM as the sample grows, and how logging before the redirect removes that cause.

Treatment
Assigned per arm
Sample ratio check for a 50/50 test with 10,000 users assigned per arm. Control logged 9,540 exposures and treatment 9,400. Treatment's share is 49.63 percent, expected 50. Chi-square is 1.0 and p is 0.309, so the ratio looks fine. Conversion among exposed users: control 4.00 percent, treatment 4.03 percent, with no real effect.control 9,540treatment 9,40049.63%40%44%48%50%52%chi-square 1.0p 0.309ratio looks fineconversion of exposedcontrol 4.00%treatment 4.03% (+0.8%)
Within the green band, the gap is consistent with chance. Raise the delay or the sample size: the band narrows as samples grow, so a leak too small to see at 10,000 users is obvious at a million.

When SRM fires, the platform helps find where. It reruns the check per segment: browser, device type, country, entry page, and new versus returning. A mismatch that appears only on Safari points to a browser-specific bug; one that appears only on the product page entry points to the redirect. The debug view lists these segment checks sorted by p-value.

Check your understanding · Exercise 7

A 50/50 test shows 100,000 control and 98,900 treatment exposures (p about 0.014). Treatment's conversion is 3 percent higher. Which conclusions are reasonable? Select all that apply.

Peeking, sample size, and the statistics the platform enforces

The ab-testing lesson explains p-values and significance. This chapter is about the ways real teams misuse them, and how the platform's design, not a training course, prevents it. The statistics run in the warehouse; the frontend's job is to collect the right inputs at setup and show outputs that can't be misread.

Plan the sample size before launch

A test needs enough units to detect the smallest effect worth acting on, the minimum detectable effect (MDE). A handy approximation for 80 percent power at a 5 percent significance level is n per group = 16 x variance / effect^2. Our delivery dates test has a 4 percent checkout rate and wants to detect a 5 percent relative lift, from 4.0 to 4.2 percent.

The variance of a 0 or 1 outcome is 0.04 x 0.96 = 0.0384, and the effect is 0.002. So n is about 16 x 0.0384 / 0.000004 = 153,600 per group, 307,200 in total.

The setup form does this math live as the team types the baseline and MDE, then converts it to days using the eligible traffic: about 29,000 exposed devices a day at 20 percent allocation gives about 11 days, rounded up to 14 so the test covers two full weeks. Weekday and weekend shoppers behave differently; a test that runs Monday to Thursday measures weekday shoppers only.

Peeking: why checking every day breaks a fixed test

The classic p-value assumes we look once, at the planned sample size. If we look every day and stop the first time p drops below 0.05, we get many chances to catch a random high point. The false positive rate climbs from 5 percent to about 22 percent over 14 daily looks, in line with the classic published figure of about 19 percent for 10 looks. Nothing in any single day's chart looks wrong.

Run the lab: it simulates 200 A/A tests, where both groups get the identical page, so every "win" is false. The batch is generated from a fixed seed, so the numbers match the text until you ask for a new batch.

Method
Days, one look per day
200 simulated A/A tests, where both arms are identical, each with 1,000 users per arm per day at a 5 percent conversion rate, over 14 days. Method: 14 looks, stop at first win. 45 of 200 tests, 22.5 percent, declared a false win. Each false win is a red square in a 20 by 10 grid.false wins: 45 of 200rate 22.5%no real difference exists14 looks, stop at first win
Every red square is a team shipping a change that does nothing. More looks mean more chances to stop on a random high, so try 28 days.

Decision How should the platform deal with people looking at results early?

Fixed horizon with locked results

The results page shows guardrails and SRM from day one, but hides the primary metric's significance until the planned sample size is reached.

Pros

  • The most statistical power for a given sample.
  • Simple to explain.

Cons

  • Teams can't stop early when the variant is clearly harmful or clearly great.
  • People will find a way to peek, for example by querying the warehouse.

Sequential testing

Use intervals designed to stay valid no matter how often they are checked: always-valid p-values and confidence sequences (such as the mixture sequential probability ratio test, mSPRT, from Johari and colleagues), or group sequential boundaries planned for a fixed number of looks.

Stop once the interval excludes zero and at least one full week has run.

Pros

  • Looking daily is safe.
  • Harmful variants can be stopped early with confidence.

Cons

  • Intervals are wider at the same sample size, so a test that runs to the end needs somewhat more traffic.
  • Harder to explain, which the results page must make up for.

Leave it to the analyst

Show the classic p-value at all times and add a warning.

Pros

  • No work for the platform.

Cons

  • The warning is ignored; the false positive rate quietly quadruples.

What we pick: Use sequential testing as the default, since people will look every day anyway, and offer fixed horizon as an option for teams that commit to the plan, with the significance locked until the planned date. Optimizely's Stats Engine brought always-valid inference to commercial A/B testing, and Statsig, Eppo, and GrowthBook document sequential options of their own. The platform stores the choice in the experiment config at launch; changing it later is not allowed, because choosing the method after seeing data is another form of peeking.

CUPED: same answer, fewer users

Much of the noise in a metric like "orders per user" comes from people being different: some shop weekly, some yearly. CUPED (Controlled-experiment Using Pre-Experiment Data, from Microsoft researchers in 2013) uses each user's value of the same metric from before the test to predict part of that difference and subtract it out. Randomization means the pre-test values are balanced between groups, so the subtraction removes noise without adding bias.

The gain depends on how well the past predicts the present. If the correlation is r, the variance shrinks by a factor of about 1 - r^2: with r = 0.6 the variance drops by 36 percent, so the test needs about 36 percent fewer users for the same precision.

New visitors have no history, so CUPED helps little on metrics dominated by first-time users. The frontend's part is small but required: stable unit ids that let the warehouse find each unit's history, and a note on the results page saying the estimate is variance-reduced.

Novelty and change aversion

A new button gets clicked because it is new; after a week it is just a button. That is a novelty effect. The opposite, change aversion, makes regular users resist a new layout before they get used to it. Both make the first days unrepresentative.

The results page plots the effect by days since each user's first exposure. A line sloping toward zero signals novelty. A line rising from below zero signals change aversion. For big redesigns, also compare new users, who have no habits to break.

Check your understanding · Exercise 8

A product manager checks a fixed-horizon test daily and wants to ship on day 4, when p = 0.03. Which responses are right? Select all that apply.

The results page: built for decisions, not for p-values

The old tool shows a table of metrics with a p-value and a green or red cell. Product managers read "p = 0.04, green" as "96 percent sure the variant is better", which is not what a p-value means, and the table says nothing about how big the effect is. A tiny, unimportant lift on a huge sample turns green; a large, promising lift on a small sample stays grey and gets thrown away.

Decision What should the results page lead with for each metric?

P-value and a color

Show the p-value; color the cell when it is below 0.05.

Pros

  • Compact, and familiar to analysts.

Cons

  • Hides the size of the effect.
  • Invites the wrong reading: a p-value is not the chance the variant is better.

Effect with a confidence interval

Show the relative change with its 95 percent interval as a bar around a zero line, plus one plain sentence.

Pros

  • Shows size and uncertainty together.
  • "Crosses zero" is easy to see without any statistics.

Cons

  • Needs a little explanation the first time.
  • Takes more space than one number.

Probability to beat control

Show a Bayesian "chance to be best" percentage.

Pros

  • The number people want to read.

Cons

  • Depends on prior choices most readers can't judge.
  • Also hides the size of the effect unless shown with an interval.

What we pick: Lead with the effect and its confidence interval, drawn as a bar, plus a sentence written by the page: "Delivery dates raised checkout completion by 1.8 percent. The true effect is likely between 0.4 and 3.2 percent." The p-value stays available on hover for analysts. Color only appears when the interval excludes zero, and it is always paired with an icon and words, so color is never the only signal.

Three metrics drawn as 95 percent confidence intervals around a zero line, on a scale from minus 4 to plus 4 percent. Checkout completion: plus 1.8 percent, interval 0.4 to 3.2, entirely above zero, so a win. Revenue per visitor: plus 0.3 percent, interval minus 1.1 to plus 1.7, crossing zero, so unclear. LCP at the 75th percentile: minus 0.9 percent, interval minus 3.1 to plus 1.3, crossing zero, so no harm shown.checkout+1.8% (0.4 to 3.2): a winrevenue/visitor+0.3% (-1.1 to 1.7): unclearLCP p75-0.9% (-3.1 to 1.3): no harm shown-4%0+4%
The eye answers the key question without any statistics: does the bar cross the zero line? Only the bar that clears zero gets a color. The width shows how much is still unknown.

The layout, top to bottom

The page reads in the order a decision should be made. First the health strip: SRM status, sample progress against the plan, days run, and data freshness ("updated 14 minutes ago"). If SRM fires, everything below is greyed out behind a banner.

Then the primary metric, large. Then guardrails as a pass or fail list. Then a decision box with the platform's suggestion: ship, don't ship, keep running, or inconclusive. Secondary metrics and segments come last, collapsed.

A wireframe of the results page for pdp-delivery-dates. Top, the health strip: SRM ok, 72 percent of the planned sample, day 10 of 14, updated 14 minutes ago. Then the primary metric, checkout completion: plus 1.8 percent, drawn as an interval bar right of a zero line. Then guardrails: revenue pass, LCP p75 pass, errors pass. At the bottom left, a decision box suggests keep running, with 4 days to the planned sample. At the bottom right, a collapsed segments section labelled exploratory, with a note to expect about 15 false wins among 300 comparisons.pdp-delivery-dates: resultsSRM ok72% of planday 10 of 1414 min agoprimary: checkout completion+1.8%0.4 to 3.2guardrailsrevenue: passLCP p75: passerrors: passsuggestion: keep running4 days to planned samplesegments (exploratory)expect ~15 false wins
Health first, because a failed SRM check greys out everything below it. One primary metric, large. Guardrails as pass or fail. Segments last, collapsed, and labelled exploratory.

Segments and the multiple-comparisons trap

Everyone wants to slice: mobile vs desktop, new vs returning, by country. With 30 metrics and 10 segments, that is 300 comparisons, and if nothing really changed, a 5 percent error rate makes about 15 of them look significant by pure chance. Someone will find "treatment wins for returning Safari users in Germany" and ship on it.

The page handles this in three ways. Segment results are labelled exploratory. Significance in the segment table is adjusted for the number of comparisons, for example with the Benjamini-Hochberg method, which controls the expected share of false wins among the wins shown. And a counter above the table says how many comparisons are shown and how many false wins to expect.

The page reads precomputed results from one endpoint and does no statistics in the browser. A full results payload for 30 metrics, 10 segments, and 2 variants is about 600 rows, around 40 KB gzipped, so it renders in one pass without virtualization. The interval bars are SVG with a text equivalent in each row for screen readers, such as "plus 1.8 percent, interval 0.4 to 3.2".

Check your understanding · Exercise 9

Revenue per visitor shows +0.3 percent with a 95 percent interval of -1.1 to +1.7 percent. How should the page describe it? Select all that apply.

Performance experiments: Core Web Vitals per arm

Some experiments change speed on purpose: a product page with 120 KB less JavaScript, server-rendered reviews instead of client-rendered ones, a different image format. These are the best way to prove that speed earns money, which is the argument the web-performance-overhaul guide needs to make. They also need two changes to the usual setup, because speed affects the page from its first byte.

  • Exposure at assignment, on the server or edge. The change is the whole page load, so the moment of exposure is the request itself (exposeOn: "request"). Waiting for a component to render would lose exactly the users the slow arm drives away, which is the SRM from the earlier chapter.
  • Web Vitals tagged with the arm. The real-user monitoring (RUM) beacon that already reports LCP, INP, and CLS adds the page's active assignments as a dimension, so the warehouse can compute each metric's 75th percentile per arm. The real-user-monitoring lesson covers collection; the core-web-vitals lesson covers the thresholds.
ts
import { onCLS, onINP, onLCP, type Metric } from "web-vitals";

function report(metric: Metric) {
  rum.queue({
    name: metric.name, // "LCP" | "INP" | "CLS"
    value: metric.value,
    rating: metric.rating,
    page: location.pathname,
    // only experiments exposed on this page, e.g. "pdp-lite.b,search-v3.control"
    experiments: experiments.exposedOnThisPage().join(","),
  });
}

onLCP(report);
onINP(report);
onCLS(report);
The RUM reporter adds assignments to each Web Vitals sample, using the web-vitals library's callbacks.
Measuring Web Vitals per arm. The web-vitals callbacks in the page send LCP, INP, and CLS to a RUM beacon, which adds the active experiment arms and sends them to the warehouse. The warehouse computes per arm: control has a p75 LCP of 2,310 ms with 97.8 percent of page views reporting an LCP; pdp-lite has 2,050 ms with 96.1 percent reporting, a gap flagged for checking. A note says fewer samples in one arm should be treated like SRM.web-vitalsLCP, INP, CLSRUM beacon+ experiment armswarehousep75 per armarmp75 LCPviews with LCPcontrol2,310 ms97.8%pdp-lite2,050 ms96.1%fewer samples in one arm: check it like SRM
The faster arm looks 260 ms better, but it also reported LCP on fewer page views. Before trusting the percentile, find out who didn't report: a missing-sample gap is a sample ratio problem in disguise.

Two traps are specific to speed. First, percentiles are not averages: the standard formulas for intervals do not apply to a 75th percentile. So the stats engine uses methods made for quantiles, such as the bootstrap, which resamples the data many times to see how much the percentile moves.

Second, missing samples. LCP is reported when the page is hidden or the user interacts, and users who leave a very slow page early may never send it. If one arm loses more samples, its percentile looks better than reality. The results page therefore shows "page views with an LCP sample" per arm next to the percentile, and treats a gap between arms like an SRM.

Check your understanding · Exercise 10

A test removes 120 KB of JavaScript from the product page. Which setup measures it correctly? Select all that apply.

Architecture, data flow, and interfaces

The system has two frontends. The product app is the marketplace shoppers use; experiments run inside it. The experiment console is an internal web app where teams set up tests, QA them, and read results. Between them sit a config service, a shared assignment library, the analytics pipeline, and the stats engine.

PartRuns inResponsibility
Experiment consoleBrowser (internal Next.js app)Setup wizard, review and launch, collision warnings, QA links, results page.
Config serviceServerStores experiments and layers; on every change compiles one JSON config (about 30 KB for 60 experiments) and pushes it to app servers and the edge.
Assignment libraryServer, edge, and browserOne pure function: config plus unit id gives assignments. One package, one version everywhere.
Edge functionCDNSets the device id cookie, assigns for cached paths, picks the cached copy by variant key.
App serverNext.js serverAssigns for dynamic pages, renders the variant, writes assignments into the HTML bootstrap.
Experiment clientBrowser, on top of the flag SDKReads the bootstrap, exposure boundaries, dedupe, QA overrides, debug panel.
Analytics SDKBrowserDelivers exposure, conversion, identify, and RUM events.
Warehouse and stats engineData platformHourly: dedupe, join conversions to exposures, SRM, effects, intervals.
Results APIServerServes precomputed results to the console.
The architecture in two columns. Left, the shopper path: an edge function that assigns cached pages, an app server that assigns and renders the variant, the experiment client in the browser that handles exposure, QA, and debug, and the analytics SDK that batches events and uses sendBeacon. Arrows go down the column: the edge passes the request to the app server, the app server sends HTML with the bootstrap to the browser, and the experiment client sends exposures to the analytics SDK. Right, the platform: the config service that compiles and pushes JSON, the console for setup, review, and results, the results API serving precomputed rows, and the warehouse computing SRM, effects, and intervals. The console sends launches up to the config service, the config service pushes config across to the edge and the app server, the analytics SDK sends events across to the warehouse, the warehouse feeds the results API hourly, and the results API serves the console. A note says the edge, app server, and experiment client share one assignment library.shopper pathplatformedge functionassigns cached pagesapp serverassigns, renders variantexp clientexposure, QA, debuganalytics SDKbatches, sendBeaconHTMLexposuresconfig servicecompiles, pushes JSONconsolesetup, review, resultsresults APIprecomputed rowswarehouseSRM, effects, intervalslaunchresultshourlyconfigeventsedge, app server, exp client: one shared assignment library
Config flows left into the request path, events flow right into the warehouse, and results flow up into the console. The one shared assignment library is what keeps the edge, the server, and the browser in agreement.

Each boundary has a reason. The assignment library is one shared package because agreement between server, edge, and browser is the whole point; three implementations in three languages would drift. The config is pushed, not fetched per request, so assignment never waits on the network and keeps working with the last known config if the config service is down.

The statistics live in the warehouse, not the console, because they need billions of rows and must produce one agreed answer that every viewer sees. The console only renders.

The data flow for one shopper follows the chain from the first chapter. The request reaches the edge or app server, which assigns and renders, and the HTML carries the assignments to the browser. The browser logs exposures and conversions through the analytics SDK.

The warehouse joins them every hour, and the console reads the results. Every arrow in the figure below carries one specific thing, and each is a place where a bug can bend the result.

Data flow for one product page view. Row one: the shopper's browser sends GET with the cf_did cookie to the edge; the edge sends GET with the variant key to the app server; the app server returns variant HTML to the edge, and the edge returns HTML with Set-Cookie to the browser. Down from the browser, the bootstrap JSON goes to the experiment client, which hydrates with the same variant. Row two: the experiment client sends exposure and conversion events to the collector, and the collector sends deduped events to the warehouse. Row three: the warehouse writes hourly rows to the results API, and the results API sends results JSON to the console.browsershopperedgeassign, cacheapp serverrender variantGET, cf_didGET, variant keyHTML, Set-Cookievariant HTMLbootstrap JSONexp clientsame variantcollectorwarehousejoin, SRM, statsexposure, conversiondeduped eventshourly rowsresults APIconsoleresults pageresults JSON
Every arrow carries one named thing, and each is a place to check when numbers look wrong: the cookie, the variant key, the bootstrap, the events, the hourly rows. The variant is decided once, at the top, and only read after that.

Think first

The config service is down for 20 minutes during a busy afternoon. What should shoppers and experiments experience?

Data model and interfaces

Each piece of data lives in exactly one place that owns it, and everything else holds a copy with a known age. The table lists where each lives on the client side; the code below shows the shapes that cross the wire.

DataLives inWhy there
Compiled configApp server and edge memory, refreshed on pushAssignment must never wait on a network call.
Assignments for this pageInline JSON in the HTML, then the experiment client's memoryThe browser must agree with what the server rendered, with no second hash.
Device idFirst-party cookie cf_did, set by the server or edge once analytics consent is givenReadable by server and edge on the first request; survives reloads.
Exposure dedupe keysMemory and sessionStorageStops repeat sends across re-renders and route changes within a tab.
QA overridesCookie cf_exp_force, 24 hoursMust reach the server so the forced variant renders on the first frame.
ResultsConsole server cache (TanStack Query), filters in the URLShareable links to a segment view; refetch on the hourly refresh.
ts
type UnitType = "device_id" | "user_id" | "account_id" | "session_id";

type Assignment = {
  experiment: string;     // "pdp-delivery-dates"
  iteration: number;      // bumps on restart, with a new salt
  variant: string;        // "dates"
  unitType: UnitType;
  source: "server" | "edge" | "client" | "override";
  exposeOn: "request" | "render" | "visible";
};

type ExposureEvent = Assignment & {
  type: "exposure";
  unitId: string;
  ts: number;             // client time; server adds received time
  page: string;
};

type IdentifyEvent = { type: "identify"; deviceId: string; userId: string; ts: number };
The client-side shapes. Every event carries the iteration, so data from a restarted test never mixes.

Overrides from QA are tagged source: "override" and the warehouse drops them, so testers never pollute results. The results API is read-only and returns precomputed rows; the console never asks for raw events.

http
GET /api/experiments/pdp-delivery-dates/results?iteration=3&segment=device:mobile

200 OK
Cache-Control: private, max-age=300
{
  "health": { "srm": { "p": 0.41, "ok": true }, "progress": 0.72, "updatedAt": "2026-10-21T09:00:00Z" },
  "method": "sequential",
  "metrics": [
    { "key": "checkout_completion", "role": "primary", "control": 0.0400, "treatment": 0.0407,
      "lift": 0.018, "ci": [0.004, 0.032], "significant": true },
    { "key": "lcp_p75", "role": "guardrail", "control": 2310, "treatment": 2290,
      "lift": -0.009, "ci": [-0.031, 0.013], "significant": false }
  ],
  "recommendation": "keep_running"
}
Results are one request per view. Segments are a query parameter, so the URL is the shareable state.

Check your understanding · Exercise 11

Why is the QA override stored in a cookie rather than in localStorage? Select all that apply.

QA, debugging, and governance

A platform where 30 teams launch 15 tests a week needs two kinds of support. Engineers and designers need to see and force any variant before launch. And the company needs rules so that tests don't collide, risky tests get a second pair of eyes, and finished tests leave the codebase.

Forcing a variant and preview links

For QA, the console generates a preview link per variant, such as /p/123?exp_preview=eyJ.... The token names the experiment and variant and is signed by the server with an expiry of 24 hours, so a link pasted into a public chat can't expose an unreleased feature for long.

When the server sees a valid token it sets the cf_exp_force cookie and renders the forced variant on the first frame. Overrides are tagged source: "override" and excluded from analysis. Staff signed into the console can also force variants with a plain ?exp_force=key:variant parameter, which the server only honors for staff sessions.

The debug panel is a staff-only overlay, opened with a keyboard shortcut, that lists every experiment evaluated on the current page: its layer, assigned variant, where it was assigned, whether exposure was logged, and any override. Each row has buttons to switch variant, which set the override cookie and reload. A toggle outlines every exposure boundary on the page, which makes a missing or asymmetric boundary visible at a glance. The panel's code is loaded with a dynamic import() only for staff, so shoppers download none of it.

A wireframe of the staff debug panel, titled experiments on this page. A table with columns experiment, layer, variant, source, and seen. pdp-delivery-dates: layer pdp-main, variant dates, assigned on the server, exposure logged. search-v3: layer search, variant control, assigned at the edge, not seen yet. checkout-copy: layer checkout, variant b, set by an override, exposure logged, highlighted. Below, two buttons: switch variant and outline boundaries. A note says overrides live in a cookie for 24 hours and are excluded from results.experiments on this page (staff)experimentlayervariantsourceseenpdp-delivery-datespdp-maindatesserveryessearch-v3searchcontroledgenocheckout-copycheckoutboverrideyesswitch variantoutline boundariesoverrides: cookie, 24 hours, excluded from results
Each row answers why this page looks the way it does. The outline toggle draws every exposure boundary on the page, which makes a missing boundary in one arm easy to spot.

The experiment lifecycle

Every experiment moves through explicit states, and the console allows only the transitions in the figure. Launch requires a passing launch checklist: hypothesis written, primary metric and guardrails set, sample size planned, layer chosen, each variant opened through a preview link. Tests that touch checkout or revenue, or start above 50 percent allocation, also need approval from a data scientist. Editing a running test's split or salt is not a transition at all: it creates a new iteration.

A state machine of an experiment's life. Draft, which anyone can create, goes to in review on submit. In review goes back to draft when changes are asked, or to running when approved after the checklist and, for risky tests, a data scientist's review. Running goes to paused and back on pause and resume. Running goes to stopped. Stopped goes to decided when a winner is picked. Decided goes to cleaned up when the losing code is removed, within 30 days. A note says an edit to the split or salt creates a new iteration.draftanyonein reviewchecklist, DSrunningassign and logsubmitchangesapprovepausedallocation 0pauseresumestoppedno new usersstopdecidedwinner at 100%pick winnercleaned upcode removed30 dayssplit or salt edit:new iteration
Only these transitions exist in the console. Editing the split or salt is not one of them: it starts a new iteration, so the data before and after the edit never mix.

Collisions between experiments

Layers prevent clashes only when teams pick the right layer. So each experiment also declares the surfaces it changes: route patterns plus named component slots, such as pdp:buy-box. When a test is submitted, the console checks running tests in other layers for overlapping surfaces and shows a warning with both owners' names. In development builds, the experiment client logs a console warning when two experiments from different layers render boundaries for the same slot on one page, which catches undeclared overlaps.

Cleaning up finished experiments

A decided experiment leaves two if branches in the code. The decision step in the console asks which variant wins and turns the experiment into a release flag serving that variant to everyone, so the change goes live without a deploy.

It then starts a 30-day clock for removing the losing branch. A CI check fails any pull request that adds a reference to a decided experiment, and a weekly report lists stale references per team. Some companies go further and generate the cleanup pull request automatically; Uber's open-source Piranha tool does this for stale flags.

Privacy and consent

In the EU, storing a device id and logging exposure for analysis generally needs the user's consent, and the cookie-consent guide covers how consent is collected and read. Our rule is simple and symmetric: users who have not granted analytics consent get no device id, are served control, and are not in any experiment. No exposures are logged for them.

If they grant consent later, they become eligible from the next page. Exposure events carry random ids, never emails or names. The results page states the population plainly, "users who accepted analytics cookies", since they may differ from everyone else.

Check your understanding · Exercise 12

Which of these should the platform enforce rather than leave to each team? Select all that apply.

Running it in the interview

This question is wide, so the risk is spending 40 minutes on hashing. The interviewer usually wants to see that you know an experiment is a measurement, and that you can name the places where a frontend bends it. Lead with that framing in the first two minutes.

MinutesWhat to doWhat to draw
0 to 6Scope: logged-out traffic, rendering, number of concurrent tests, who reads results.Nothing yet; write the answers in a corner.
6 to 12The chain: design, assign, render, expose, convert, analyze, decide. Say where each can break.The chain as a row of boxes.
12 to 25Unit of randomization, layers, salted hashing, sticky edits, identity stitching.Layer bucket ranges.
25 to 38Server or edge rendering of variants vs visual editors; exposure on visibility, symmetric.The architecture and the exposure sequence.
38 to 50SRM, peeking and sequential testing, CUPED, the results page.The results page wireframe.
50 to 60QA, governance, consent, cleanup; then follow-ups.The lifecycle states if asked.

Say the numbers out loud: "a 400 ms anti-flicker delay adds to LCP for everyone", "logging at assignment dilutes this test about eight times", "14 daily looks push false positives from 5 to about 22 percent". Numbers show you have reasoned about the system rather than memorized the words.

Think first

Follow-up: "Marketing refuses to give up the visual editor. What do you do?"

Think first

Follow-up: "An experiment shows SRM only on iOS Safari. How do you debug it?"

Think first

Follow-up: "Why not use a multi-armed bandit and skip the waiting?"

Summary

  • An experiment is a measurement. Design the platform so wrong results are hard to produce and easy to detect, not just so variants are easy to show.
  • Pick the unit of randomization on purpose: device id for logged-out surfaces, user id behind login, account for shared features. Keep it fixed for the life of the experiment and analyze per the same unit.
  • Use layers: exclusive within a surface, overlapping across surfaces. Salt each experiment and each restart, and treat split changes as new iterations.
  • Render variants on the server or at the edge with per-variant cache keys. Client-side DOM changes cause flicker and CLS, and anti-flicker hiding moves the cost onto LCP for everyone.
  • Log exposure when the change is seen, with the same trigger in both arms, from one source of truth for assignment, deduped on the client and again in the warehouse.
  • Check sample ratio mismatch on every refresh and block the decision when it fires. In frontends it usually means a slow variant, a redirect, a crash, or a cache.
  • Plan the sample size, use sequential testing if people will look daily, and show effects with confidence intervals and plain sentences, with segment results marked exploratory.
  • Govern the lifecycle: preview links and a debug panel for QA, review for risky tests, collision checks on surfaces, consent-aware eligibility, and cleanup of finished tests into permanent code.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next