cf.completefrontend
My progress
FUNDAMENTALS · MODULE 15

A/B testing

Showing different versions of a page to randomly split groups of users and measuring which works better: stable bucketing, no flicker, honest statistics, and guardrails that catch harm.

The problem: did the change cause it?

Your team believes a bigger "Add to cart" button will sell more. You ship it on a Tuesday, and by Friday orders are up 4%. The team celebrates.

Then someone notices that Friday was payday, a competitor had an outage on Wednesday, and marketing sent a coupon email on Tuesday. Was it the button? You can't tell.

This is the problem with before and after comparisons. Many things change at the same time as your release, and any of them can move your numbers more than your change did, or hide a change that hurts.

An A/B test (also called a controlled experiment or a split test) fixes this by comparing at the same time instead of before and after. Each visitor is assigned at random to one of two groups. Group A, the control, sees the current page. Group B, the treatment, sees the new button.

Both groups live through the same payday, the same outage, and the same coupon email. The only systematic difference between them is the button.

Two ways to judge a bigger Add to cart button over one week. On the left, before and after: the old button runs on Monday and the new one from Tuesday to Friday, while a coupon email goes out on Tuesday, a rival has an outage on Wednesday, and Friday is payday, so a 4% rise in orders cannot be pinned on the button. On the right, an A/B test in the same week: group A sees the old button and group B the new one every day, so both groups get the coupon, the outage, and payday, and the difference between them is the button.before and afteroldnewnewnewnewMonTueWedThuFriTue: coupon emailWed: rival outageFri: paydayorders +4%: which one did it?A/B test, same weekAoldoldoldoldoldBnewnewnewnewnewboth groups get the coupon,the outage, and paydayB minus A = the button
Before and after mixes the change with everything else that happened that week. A split test exposes both groups to the same week, so the gap between them belongs to the change (or to luck, which statistics measures).

Why random assignment is the whole trick

Random assignment means every kind of user (mobile and desktop, new and loyal, rich and careful) ends up in both groups in about the same proportions. So when the treatment group converts better, the only explanations left are the change itself or plain luck. Statistics tells you how likely luck is. That is the full logic of an experiment, and it can break at any of four steps.

  • Assignment must be truly random and stable, or the groups differ in more than the change.
  • Exposure must match assignment: a user in B must really see B, without a flash of A first.
  • Logging must record who saw what, exactly once, or the counts you compare are wrong.
  • Analysis must account for luck honestly, or you will ship noise and call it a win.

Why this is a frontend topic

Data scientists own the statistics, but the frontend owns most of the places where an experiment silently breaks. The browser decides when the variant renders, so it decides whether users see a flicker. The client fires the exposure event, so it decides whether the counts are right. The client adds code for both variants, so it decides whether the treatment is slower for reasons that have nothing to do with the idea being tested.

A broken experiment is worse than no experiment. With no experiment, the team knows it is guessing. With a broken one, the team is confident and wrong.

This lesson builds on the feature flags lesson: an experiment is a flag with a random split and a measurement attached.

The mental model: a coin flip, a stamp, and a ledger

Picture a clinic testing a new medicine. At the door, each patient flips a coin that decides their group, and a stamp on their hand records the result, so they get the same treatment on every visit. A nurse writes in a ledger the moment they actually take a pill, not the moment they walk in. At the end, a statistician compares the two groups in the ledger.

A web experiment has the same parts. The coin flip is assignment, and the stamp is stickiness: the user keeps their variant on every page and every visit. The pill is exposure: the moment the user actually sees something that differs between variants. The ledger is the exposure log, and the statistician is the analysis.

The experiment pipeline. An experiment config with the key, split, and metrics feeds step 1, assign, which hashes the experiment key and user id at the edge or server. Then step 2, expose: the variant renders in the browser. Then step 3, log: an exposure event is sent. Then step 4, analyze: the SRM check, metrics, and intervals. What breaks at each step: assignment can be biased or not sticky; exposure can flicker so users see both; logging can lose or double count events; analysis can suffer from peeking or the wrong unit.experiment configkey, split, metricspublished to SDKs and the edge1. assignhash(key:id)biased ornot sticky2. exposevariant rendersflicker:saw both3. logexposure eventlost orcounted twice4. analyzeSRM, metricspeeking,wrong unitwhat breaks at each station
Every experiment passes through the same four stations. When a result looks wrong, check them in order: a broken station upstream makes everything downstream meaningless.

The words you need

TermMeaningExample
ExperimentOne question, with a key, variants, a traffic share, and metricscheckout-button-size
VariantOne version being compared; control is the current onecontrol, big-button
UnitWhat gets randomized: usually a user, sometimes a device, session, or page viewuser u_42, or cookie cid_9f3a
AllocationThe share of all traffic that enters the experiment at all20% of users
SplitHow the allocated users divide between variants50/50 inside the 20%
ExposureA user actually saw a part of the page that differsthe checkout button rendered
MetricA number computed per variant from logged eventsorders per user

Choosing the unit

The unit of randomization is the thing you flip the coin for. For most product tests it is the user: one person should see one version, or the experience feels broken and their behavior is a mix of both. For a logged-out site, the user is approximated by a random id in a first-party cookie, which really means one browser.

Smaller units, such as a session or a page view, give more samples but leak between groups: a Tuesday purchase may be caused by the button seen on Monday. Use them only for changes users cannot notice across views, such as a faster image server.

What an experiment looks like as data

Experiment platforms (Optimizely, LaunchDarkly, Statsig, GrowthBook, Eppo, or an in-house service) all store something like the definition below. The SDK in the server or browser reads it and computes the variant locally, so no network call is needed per user.

json
{
  "key": "checkout-button-size",
  "unit": "userId",
  "allocation": 0.2,
  "variants": [
    { "key": "control", "weight": 0.5 },
    { "key": "big-button", "weight": 0.5 }
  ],
  "layer": "checkout",
  "primaryMetric": "orders_per_user",
  "guardrails": ["lcp_p75", "js_error_rate", "refund_rate"]
}
An experiment definition. Allocation and split are separate so you can grow traffic without reshuffling users.

Check your understanding · Exercise 1

Which changes are reasonable to randomize per page view instead of per user? Select all that apply.

Assignment and bucketing: the fair, sticky coin

Assignment has two jobs that pull against each other. It must be random, so the groups are alike. It must also be sticky, so the same user gets the same answer on every request, on every server, today and next week.

A call to Math.random() is random but not sticky. Storing every user's draw in a database is sticky but needs a lookup on every page.

The standard answer, the same one the feature flags lesson uses for rollouts, is a deterministic hash. Feed the experiment key and the unit id into a fast hash, for example checkout-button-size:u_42. The output looks random across users, but it is always the same for the same input. Every server and every browser computes the same bucket without talking to anyone.

Two hashes: one for allocation, one for the variant

Map the hash to a fine range, such as 0 to 9,999, so you can allocate 0.5% of traffic. Then decide two things: is this user in the experiment at all (allocation), and which variant do they get (split)? A subtle bug appears if one bucket number decides both.

Say allocation is 20% and the split is 50/50 on one bucket: buckets 0 to 999 are control and 1,000 to 1,999 are treatment. Now you grow allocation to 40%, so control becomes 0 to 1,999. Every user in buckets 1,000 to 1,999 silently moves from treatment to control, and their history now pollutes both groups.

The fix is to hash twice with different salts (a fixed extra string added to the hash input, such as alloc or variant), so growing the allocation only adds users and never moves anyone.

ts
type Variant = { key: string; weight: number };

function bucket(input: string): number {
  return hash32(input) % 10_000; // 0..9999
}

function assign(exp: { key: string; allocation: number; variants: Variant[] }, unitId: string) {
  // 1. In the experiment at all? Growing allocation only adds users.
  if (bucket(`${exp.key}:alloc:${unitId}`) >= exp.allocation * 10_000) return null;

  // 2. Which variant? Independent of allocation, so nobody switches.
  const b = bucket(`${exp.key}:variant:${unitId}`);
  let edge = 0;
  for (const v of exp.variants) {
    edge += v.weight * 10_000;
    if (b < edge) return v.key;
  }
  return exp.variants[exp.variants.length - 1].key;
}
Assignment with separate hashes for allocation and variant. hash32 is any well-mixed 32-bit hash, such as MurmurHash3 or FNV-1a plus a finalizer.
The bucket range 0 to 9,999 drawn as ten slices of 1,000. With two separate hashes, the allocation hash puts the first two slices in the test (20%) and the rest out, and an independent variant hash splits all ten slices half A and half B; growing allocation only adds slices. With one shared hash at 20%, slice 1 is A and slice 2 is B. Grown to 40% with one shared hash, slices 1 and 2 become A and slices 3 and 4 become B, so the users in slice 2 switch from B to A.allocation hash, 20%: in the test?ininoutoutoutoutoutoutoutoutvariant hash, 50/50: which variant?AAAAABBBBBone shared hash at 20%ABoutoutoutoutoutoutoutoutone shared hash grown to 40%AABBoutoutoutoutoutoutseparate: safe to growwas B, now Aeach slice = 1,000 buckets
Two hashes keep the two decisions independent: allocation can grow without moving anyone. With one shared hash, the variant boundaries move when allocation grows, and the users in the red slice silently switch from B to A.

Include the experiment key in the hash input. Otherwise the same slice of users lands in treatment for every experiment, and their combined exposure biases all of them.

Sticky variants: when the hash is not enough

A pure hash is sticky only while the unit id and the targeting rules stay the same. If the experiment targets the free plan and the user upgrades, the rule now excludes them, and the next page shows control.

The fix is persisted assignment, which GrowthBook calls sticky bucketing and Optimizely provides through its User Profile Service. On first assignment, store experimentKey: variant in a cookie or on the user record, and read it before running the hash and rules. Version it, so a restarted experiment does not reuse stale answers.

Logged out, then logged in: identity stitching

Most sites have visitors who arrive anonymous and log in later. Before login, the unit is a random anonymous id stored in a first-party cookie, such as cid_9f3a. After login, the natural unit is the account id, u_42. Hash the new id and you get a new bucket, so about half of users switch variant at the moment they log in, which is exactly the moment many funnels start.

A sequence diagram with four columns: Laptop, Server, Assignments store, and Phone. The laptop asks for the home page with no cookie; the server assigns variant B by the cookie id cid_9f3a and replies with HTML B and the cookie. The laptop logs in as u_42; the server stores u_42 equals B, copied from the cookie id, and replies with HTML B again, so nothing changes at login. Later the phone asks for the home page signed in as u_42; the server reads the stored assignment for u_42, gets B, and replies with HTML B.LaptopServerAssignmentsPhoneGET /, no cookieHTML B, cid_9f3alog in as u_42save u_42 = Bstill HTML BGET / as u_42read u_42BHTML B
The first assignment is made by the anonymous cookie id and copied onto the account at login. The user sees the same variant before and after logging in, and on a second device.
  • Keep the anonymous id as the unit for the whole experiment. Stable on one browser, but a phone and a laptop may see two variants.
  • Persist the first assignment and copy it onto the account at login, so other devices read the same variant.
  • Use the account id only, on pages behind login. Clean and cross-device, but it cannot test the signup flow.

Whatever you choose, log both ids with every event and keep an alias record (cid_9f3a became u_42) so analysis can join anonymous browsing to later purchases. That join is the job of the analytics pipeline, covered in the analytics tracking lesson.

Exposure logging: write the ledger at the right moment

Assigning a user is not the same as exposing them. A checkout test may assign every visitor on the home page, but only 8% reach checkout. The other 92% never saw a difference, and their zero effect dilutes the real one. This dilution can turn a clear 5% lift into an unreadable 0.4%.

So log an exposure event at the moment the variant is actually used: when the button renders, or when the code path runs. The analysis then compares only exposed users, which is called triggered analysis. The rule must be the same in both groups: control users log an exposure at the same place, even though nothing visible changed for them.

json
{
  "event": "experiment_exposure",
  "experiment": "checkout-button-size",
  "variant": "big-button",
  "anonymousId": "cid_9f3a",
  "userId": "u_42",
  "ts": "2026-10-19T10:15:02.120Z",
  "page": "/checkout"
}
An exposure event. Deduplicate per unit and experiment, for example once per session, to keep volume down.

Check your understanding · Exercise 2

Your experiment runs at 10% allocation, 50/50 split. Which actions keep every user in the variant they already have? Select all that apply.

Trust the counts first: sample ratio mismatch and A/A tests

Before you look at any result, look at how many users each group has. If you asked for a 50/50 split, you should get close to 50/50. Random chance makes the groups differ a little, but only a little. When the gap is too big to be chance, the experiment has a sample ratio mismatch (SRM), and every metric in it is suspect.

Why so strict? A mismatch means some users went missing from one group, and they almost never go missing at random. If the treatment page is slower and 2% of its users leave before the exposure event fires, the users who remain are the patient ones, who also buy more. The treatment then looks better precisely because it was worse.

The check: a chi-square test on the counts

The test asks: if the split were truly 50/50, how likely is a gap at least this big? With 100,000 exposed users, you expect 50,000 in each group. A result of 50,200 and 49,800 is normal noise (p is about 0.21).

A result of 50,600 and 49,400 is not: the chi-square statistic is 14.4 and p is about 0.00015. Many teams raise an SRM alarm when p is below 0.001 and hide the results until someone explains it.

ts
function srmPValue(control: number, treatment: number, controlShare = 0.5) {
  const n = control + treatment;
  const expC = n * controlShare;
  const expT = n - expC;
  const chi2 = (control - expC) ** 2 / expC + (treatment - expT) ** 2 / expT;
  return erfc(Math.sqrt(chi2 / 2)); // p-value for 1 degree of freedom
}

srmPValue(50_200, 49_800); // about 0.21: fine
srmPValue(50_600, 49_400); // about 0.00015: investigate
The chi-square goodness-of-fit test for a two-way split, one degree of freedom. erfc is the complementary error function.
A 50/50 test with 50,000 users: 25,300 in control and 24,700 in treatment, 300 users away from the expected 25,000. Normal noise is about plus or minus 219 users and the alarm level is 368. The chi-square p-value is 0.007. Suspicious but below the alarm level. Keep watching.control 25,300, treatment 24,700perfect 50/50gap +300 users, p = 0.007noise about ±219, alarm beyond ±368
The shaded band is where 95% of healthy 50/50 splits land; the dashed box is the alarm level (p below 0.001). The dot is your gap from a perfect split. The band grows only with the square root of the users, so the same percentage gap becomes alarming as the test gets bigger.

Chi-square 7.2, p = 0.007. Suspicious but below the alarm level. Keep watching.

Try the calculator. Notice that the same 1.2% gap is harmless with 5,000 users and alarming with 500,000. Large experiments detect smaller leaks, which is good: a leak of 0.5% of users is enough to fake a 0.5% lift.

Where the missing users go

Almost every SRM traces back to the frontend or the logging pipeline.

  • The exposure event is lost more in one variant. A slower treatment loses users before the event sends. Fire it early, with navigator.sendBeacon or fetch with keepalive.
  • A redirect in one arm. Redirect tests lose the users whose redirect fails or who press back.
  • Cookies that do not last. Safari's tracking prevention caps cookies set by script (document.cookie) at 7 days. A user who returns after 8 days gets a new anonymous id, a new coin flip, and counts twice. Set the id cookie from the server with Set-Cookie instead.
  • Bot filtering that differs by variant, because the treatment changes page structure.
  • Caching. A CDN that caches one variant's HTML without the variant in the cache key serves it to both groups.
  • Exposure logged in different places, such as on render in one arm and on click in the other.

A/A tests: test the test

An A/A test splits users into two groups that see exactly the same thing. Nothing should differ. If your platform reports a significant difference in an A/A test far more often than 5% of the time (at a 5% threshold), something in assignment, logging, or analysis is broken. Run one whenever you build or change the pipeline, and keep a small A/A test running all the time as a smoke alarm.

Check your understanding · Exercise 3

A 50/50 test shows 251,900 users in control and 248,100 in treatment (p well below 0.001). The treatment adds a 90 KB carousel library above the fold. What are likely explanations? Select all that apply.

Avoiding flicker: show the right variant on the first paint

Flicker (also called the flash of original content) is when a user in the treatment sees the control version for a moment, then watches it change. It happens whenever the variant is applied after the first paint: the HTML arrives with the old headline, the browser paints it, then an experiment script downloads, decides the variant, and rewrites the DOM.

Flicker is not only ugly; it breaks the experiment. Treatment users saw both versions, so you are no longer comparing A with B but A with A-then-B. The change itself draws attention (movement catches the eye), and layout shift from the swap hurts CLS (Cumulative Layout Shift), the Core Web Vitals score for visual stability. Some users click the old button just before it moves.

The anti-flicker snippet and what it costs

Client-side testing tools that edit the page visually (VWO, Optimizely Web, and Google Optimize before it shut down in 2023) offer an anti-flicker snippet: a small inline script in the head that hides the page, usually by setting opacity: 0 on the html element, until the experiment script has run or a timeout has passed. Google Optimize's default timeout was 4,000 ms.

html
<style>.ab-hide { opacity: 0 !important }</style>
<script>
  document.documentElement.classList.add("ab-hide");
  window.abReveal = function () {
    document.documentElement.classList.remove("ab-hide");
  };
  setTimeout(window.abReveal, 4000); // fallback if the tool never loads
</script>
<script src="https://cdn.example-ab.com/project-123.js" async></script>
The shape of a typical anti-flicker snippet. The page stays invisible until the tool calls reveal() or the timeout fires.

This trades flicker for a blank page. The page may be ready to paint at 600 ms, but nothing paints until the tool's script has run, say at 1,600 ms. LCP (Largest Contentful Paint) moves out by the whole script time for every user, including control, and users whose browser blocks the script stare at a white page until the timeout.

Three timelines for a user assigned to variant B, from 0 to 2,000 milliseconds. Client only: HTML and CSS until 600 ms, then variant A is painted until the testing script runs at 1,600 ms and swaps in B, so the user sees a flicker. With an anti-flicker snippet: HTML and CSS until 600 ms, then a blank, hidden page until 1,600 ms, then B; no flicker, but the largest paint waits until 1,600 ms. Edge or SSR: the HTML already contains B, ready at 620 ms, and B is painted at once.client onlyHTML, CSSA shown to B userBpaints A at 600 ms, B jumps in at 1,600 msanti-flickerHTML, CSShidden: blankBno flicker, but LCP waits for the tool: 1,600 msedge or SSRHTML BB painted at onceright variant on the first paint: LCP 620 ms0 ms5001,0001,5002,000
An anti-flicker snippet doesn't remove the wait, it hides it: LCP moves from 600 ms to whenever the testing script finishes. Deciding at the edge or on the server costs a few milliseconds and paints the right variant first time.

The real fixes: decide before the HTML is built

Flicker disappears when the variant is known before the first byte of HTML is generated. There are three places to make that decision, and they are the same three places the feature flags lesson uses for flag evaluation.

Decision Where should the variant be decided so users never see the wrong one?

On the origin server (SSR)

Server reads the unit id cookie, runs assign(), renders the variant

Puts the assignment into the HTML so the client hydrates the same tree

Pros

  • No flicker, no hidden page
  • Full access to user data for targeting

Cons

  • Per-user HTML is hard to cache in a CDN
  • Needs an SDK in your server language

At the edge (CDN worker or middleware)

Edge function hashes the cookie id into a variant

Rewrites the request to a cached page per variant, such as /home to /home/v/big-button

Pros

  • No flicker, and pages stay cached: one copy per variant
  • Works with static pages and incrementally regenerated (ISR) pages

Cons

  • Only the edge's data for targeting (country, device, cookies)
  • Cache keys multiply with each concurrent experiment

In the browser, with values bootstrapped

Server or edge inlines the assignments as JSON in the HTML

Client SDK starts with those values instead of fetching

Pros

  • Interactive experiments (after clicks) work with no extra request
  • Easy for single-page apps

Cons

  • Something on the server still has to decide or fetch the values
  • A client SDK still ships to every user

In the browser, after load (visual editor tools)

Third-party script edits the DOM after it loads

Anti-flicker snippet hides the page meanwhile

Pros

  • Marketers can run tests without engineers
  • No backend changes

Cons

  • Flicker or a blank page, and a slower LCP for all traffic
  • Blocked by content blockers, so those users silently leave the test

What we pick: For anything above the fold, decide on the server or at the edge and bootstrap the result into the client so hydration matches. Use client-side evaluation for changes the user reaches later, such as a dialog after a click. If you must use a visual editor tool, hide only the tested region with a short timeout (300 to 500 ms), not the whole page for 4 seconds.

ts
export default async function middleware(req: Request) {
  const url = new URL(req.url);
  if (url.pathname !== "/") return fetch(req);

  let cid = readCookie(req, "cid");
  const isNew = !cid;
  if (!cid) cid = crypto.randomUUID();

  const variant = assign(homeHeroExperiment, cid) ?? "control";
  url.pathname = `/variants/home/${variant}`; // cached per variant

  const res = await fetch(new Request(url, req));
  const out = new Response(res.body, res);
  if (isNew) out.headers.append("Set-Cookie", `cid=${cid}; Path=/; Max-Age=31536000; Secure; SameSite=Lax`);
  return out;
}
Edge assignment in a fetch-style middleware. Each variant is a separate cached page; the cookie keeps the user sticky.

Two details make the edge approach safe. The cache key is the rewritten path, not the cookie, so the CDN keeps exactly one copy per variant instead of one per user. And the cookie is set by the server with Set-Cookie, so browser limits on script-written cookies do not shorten it.

Check your understanding · Exercise 4

A team adds an anti-flicker snippet with a 4,000 ms timeout to run a headline test. What should they expect? Select all that apply.

Metrics: one to decide, some to explain, guardrails to protect

An experiment is only as good as the question it measures. Pick metrics before the test starts and write them down. If you choose after seeing the data, you will find something that moved: with 40 metrics at a 5% threshold, you expect 2 false wins from noise alone.

A tree of metrics under the ship decision. Primary metric: decides; for example orders per user. Secondary metrics: explain the result; for example add to cart and checkouts. Guardrails: must not get worse; for example LCP and INP at p75, JS errors, and refunds. Trust checks: the test is valid; for example the sample ratio and the exposure count.ship it?primarydecidesordersper usersecondaryexplainsadd to cartcheckoutsguardrailsmust not worsenLCP, INPJS errorsrefundstrusttest is validsample ratioexposures
Only one branch decides. Guardrails can veto a win, and a failed trust check vetoes everything, including the guardrails.

The primary metric: the one that decides

The primary metric is the single number the decision rests on, such as orders per user or the share of users who finish signup. Some companies combine a few into an overall evaluation criterion, but the idea is the same: one agreed-upon definition of better, written down before launch.

A good primary metric is close to the change (a checkout button should move checkout completion, not monthly active users), sensitive enough to move within a few weeks, and aligned with long-term value. Clicks are easy to move and often misleading: a bright, vague button gets more clicks and fewer purchases.

Secondary metrics: the why

Secondary metrics explain the primary one. If orders went up, did more people add to cart, or did the same people finish more often? They help you learn, but they don't decide. Treat a lone secondary win with suspicion: it is one of many numbers, and some will move by chance.

Guardrail metrics: what must not get worse

Guardrail metrics are the things a test is not trying to change but must not harm. A new hero carousel may lift clicks while making the page 400 ms slower and adding errors on old Android phones. Guardrails catch that. A good set has four groups.

GroupExamplesWhat a breach usually means
PerformanceLCP, INP (Interaction to Next Paint), and CLS at p75 (the 75th percentile); JS bundle size; TTFB (time to first byte)The variant ships more code, more images, or a slower render path
ReliabilityJS error rate per page view, failed API calls, crash-free sessionsA bug only in the variant, often on a browser nobody tested
Business healthRevenue per user, refunds, unsubscribes, support tickets, page load abandonsThe variant wins its metric by pushing a cost elsewhere
TrustSRM check, exposure count, share of users with both variantsThe experiment itself is broken; don't read any result

Frontend engineers should own the first two rows. Tag every real-user monitoring (RUM) beacon and error report with the active experiment variants, as the real-user monitoring and frontend logging lessons describe, so you can compare p75 LCP and error rate per variant on the same dashboard as the business metrics.

Metric shapes and their traps

ShapeExampleTrapUsual fix
Proportion per userShare of users who orderedFew; the simplest to analyzeTwo-proportion test
Mean per userRevenue per userHeavy tails: one buyer of 50 laptops swings the meanCap (winsorize) at, say, the 99.9th percentile
Ratio of two countsClicks per page viewUnit is the user, but the denominator is views; naive errors are too smallDelta method or bootstrap by user
Percentilep75 LCP per variantMix shift: if the variant keeps slow-device users longer, p75 rises without any page getting slowerCompare per segment (device, country) too

The last row matters for frontend work. If the treatment makes the site usable on cheap phones, more cheap-phone users stay, and the variant's p75 LCP rises even though every page got faster. Split by device class before calling a performance guardrail breached.

Making metrics more sensitive

The noisier a metric, the more users you need. Three ways to reduce noise without more traffic: triggered analysis (only exposed users), capping outliers, and CUPED (Controlled-experiment Using Pre-Experiment Data), which subtracts the part of each user's metric that was predictable from their behavior before the test.

Check your understanding · Exercise 5

A test of a new image carousel wins on its primary metric, clicks on products, by 6%. Which findings should block shipping? Select all that apply.

Statistics you need: p-values, intervals, sample size, and peeking

You don't need to be a statistician to run experiments, but you do need to read results without fooling yourself. This chapter covers the five ideas that come up in every review and every interview: the p-value, the confidence interval, the minimum detectable effect, the sample size, and the peeking problem.

The p-value: how surprising is this if nothing changed?

Start from the boring assumption that the change does nothing, called the null hypothesis. The p-value answers one question: if that were true, how often would random assignment alone produce a difference at least as big as the one you saw? A p-value of 0.03 means 3% of the time.

Teams pick a threshold in advance, usually 0.05, and call a result statistically significant when p is below it. That threshold is a contract: across many experiments where the change truly does nothing, about 5% will still look significant. Those are false positives. The p-value is not the chance the change works, and it says nothing about how big the effect is.

Confidence intervals: the range of believable effects

A confidence interval is more useful than a p-value because it shows size and uncertainty together. Worked example: 122,000 users per group, control converts at 5.00%, treatment at 5.25%. The difference is +0.25 percentage points, and the 95% confidence interval is about +0.08 to +0.42 points, or a relative lift of roughly +1.5% to +8.5%.

  • The interval excludes zero, so the result is significant at the 5% level. The two statements are the same test viewed two ways.
  • The interval is wide. The true lift could be a small +1.5% or a large +8.5%. Report the range, not just the point estimate of +5%.
  • An interval that straddles zero (say -0.6% to +4.1%) does not prove no effect. It means this test could not tell.
Three test results drawn as 95% confidence intervals for the relative lift, on an axis from minus 6% to plus 14% with a line at zero. Test A, 122,000 users per group: estimate plus 5%, interval plus 1.5% to plus 8.5%, entirely above zero, a real win. Test B, 122,000 users per group: estimate plus 1.8%, interval minus 1.7% to plus 5.3%, crossing zero, so the test can't tell. Test C, 20,000 users per group: estimate plus 4%, interval minus 4.6% to plus 12.6%, very wide because of too few users.A: 122k each+1.5% to +8.5%: a real winB: 122k each-1.7% to +5.3%: can't tellC: 20k each-4.6% to +12.6%: too few users-5%0+5%+10%
Read the whole interval, not the dot. A crosses no zero and is a win. B and C both cross zero, but for different reasons: B's effect may be small, C simply had too few users.

Minimum detectable effect, power, and sample size

Plan the size of a test before you start. Power is the chance of detecting a real effect of a given size; 80% is the usual target. The minimum detectable effect (MDE) is the smallest true effect the test has that 80% chance of catching. Smaller effects need many more users, because the noise shrinks only with the square root of the sample.

ts
function usersPerGroup(baseline: number, relativeMde: number) {
  const p1 = baseline;
  const p2 = baseline * (1 + relativeMde);
  const z = 1.96 + 0.84; // 5% two-sided threshold + 80% power
  return Math.ceil((z ** 2 * (p1 * (1 - p1) + p2 * (1 - p2))) / (p2 - p1) ** 2);
}

usersPerGroup(0.05, 0.10); // about  31,000
usersPerGroup(0.05, 0.05); // about 122,000
usersPerGroup(0.05, 0.02); // about 752,000
Users needed per group for a two-sided test at a 5% threshold and 80% power, comparing two conversion rates.

Halve the effect and you need four times the users. With 20,000 exposed users a day split 50/50, the 5% lift on a 5% baseline needs a little over 12 days, which teams round up to two full weeks. The 2% lift needs 75 days, which usually means the test is not worth running as designed: find a more sensitive metric, trigger closer to the change, or accept a larger MDE.

The peeking problem

Dashboards update live, so it is tempting to check every day and stop as soon as the result turns significant. This is called peeking, and it breaks the 5% contract. Each look is another chance for noise to cross the line, and you stop on the lucky one.

How bad is it? We simulated 20,000 A/A tests (no real difference) for each duration, with 10,000 users a day at a 5% baseline. Looking once at the end produced a false positive about 5% of the time, as promised.

Looking every day and stopping at the first p below 0.05 produced a false positive about 17% of the time over 7 days, 22% over 14 days, and 28% over 28 days. The longer you keep peeking, the more tests with no real effect end up looking like winners.

The measured lift and its 95% confidence interval for each day of a 14-day test with 10,000 exposed users a day and a true effect of +5.0% on a 5% baseline. After 14 days: lift +4.1%, 95% interval -0.5% to +8.7%, p = 0.078: not significant.+30%0-30%day 1day 14
Each vertical line is the 95% interval for the lift using all data up to that day; the dot is the estimate. Intervals shrink as users pile up. The dashed line is the true effect. With peeking on, the test stops at the first day p drops below 0.05.

After 14 days: lift +4.1%, 95% interval -0.5% to +8.7%, p = 0.078: not significant.

To detect a true +5.0% lift on a 5% baseline with 80% power you need about 121,983 users per group, about 25 days at this traffic. Over 400 simulated runs, this design found the lift 60% of the time.

In 400 simulated A/A tests at this traffic and duration: 6% false positives when reading once at the end, 22% when stopping at the first daily p below 0.05.

Use the lab: choose "no effect" and turn on "stop at first p below 0.05". Press "Run again" a few times and watch how often the early-stop rule declares a winner that does not exist. Then set a real effect and see how traffic and duration change the width of the interval and the users needed.

Sequential testing: peeking done honestly

Teams want to look early for good reasons: to stop a harmful test fast, or to ship a big win sooner. Sequential testing methods allow that by making each early look stricter. Group sequential designs plan a few looks and spend the 5% budget across them; always-valid methods let you look at any time.

The price is width: a sequential interval is wider than a fixed one on the same data. Methods vary by vendor, so read your platform's documentation before trusting its "stop early" button. Without sequential statistics, fix the duration in advance and read the result once.

More variants, more metrics, more false wins

Every extra comparison is another lottery ticket for noise. Test four variants against control on ten metrics and you make 40 comparisons; at 5% each, about two will look significant by chance. Name one primary metric in advance, limit variants to what you can power, and when you must compare many, use a correction such as Bonferroni (divide the threshold by the number of comparisons).

Check your understanding · Exercise 6

A test planned for 14 days shows p = 0.03 on day 4, with the interval for the lift at +0.4% to +9.8%. What is right? Select all that apply.

Time effects and running many experiments at once

Two things can mislead an experiment even when assignment, logging, and statistics are all correct: time and other experiments. Users react to change itself, traffic changes through the week, and your test is rarely the only one running.

Novelty and change aversion

A novelty effect is when users engage with something because it is new, not because it is better. A redesigned menu gets extra clicks in week one as people explore it, then settles back. The opposite is change aversion (sometimes called the primacy effect): loyal users stumble on a moved button and do worse at first, then adapt and maybe do better than before.

A bar chart of the lift in menu clicks for a redesigned menu, week by week over six weeks: plus 9% in week 1, plus 5.5% in week 2, plus 3.5% in week 3, plus 2.5% in week 4, plus 2.1% in week 5, and plus 2% in week 6. A dashed line marks the lasting effect of about plus 2%.dashed: lasting +2%+9%week 1+5.5%week 2+3.5%week 3+2.5%week 4+2.1%week 5+2%week 6week 1 overstates the lift
Users click the new menu because it is new, and the effect fades. A one-week test would report +9%; the change is really worth about +2%. Plotting the lift over time is how you see it.
  • Plot the effect by day or week, not only the total. A lift that shrinks steadily is a sign of novelty.
  • Split new and returning users. New users have no old habit to compare against, so their result is closer to the long-term effect.
  • Keep a holdout for big changes: a small group (say 5%) stays on the old experience for months after launch, so you can measure the lasting effect.

Seasonality: run whole weeks

Traffic on Tuesday is not traffic on Saturday. Weekday visitors may be office workers on desktops; weekend visitors browse on phones and buy differently. A test that runs Monday to Thursday describes weekday users only. Run experiments in whole weeks (7, 14, or 28 days), even if the sample size is reached sooner.

Watch the calendar too: a result measured during a big sale or a holiday may not hold the rest of the year.

When experiments interact

Large products run hundreds of tests at once. Usually that is fine: because each experiment hashes with its own key, other tests' variants spread evenly across both of your groups and add noise, not bias.

Trouble starts when two tests change the same thing. Test 1 makes the checkout button green; test 2 makes the button's text green. A user in both treatments gets green text on a green button that nobody can read. Neither test alone shows the problem, and both results are muddled.

This is an interaction effect. Published experience from large platforms suggests strong interactions are rare, but when they happen they are usually between tests on the same surface.

Layers: exclusive inside, overlapping across

The common fix, described in Google's 2010 paper on overlapping experiments, is layers (also called namespaces or universes). Each layer owns one area of the product, such as checkout, search ranking, or the home page. Inside a layer, experiments are mutually exclusive: the layer's hash splits users into slots, and each experiment gets its own slots, so no user is in two checkout tests. Across layers, experiments overlap freely, because a different layer hash shuffles users again.

Three experiment layers stacked, each with 10,000 slots chosen by its own hash. Checkout layer: button-color has slots 0 to 4,999, express-pay has 5,000 to 7,999, and 8,000 to 9,999 are free. Search layer: ranking-v2 has all slots. Home page layer: hero-copy has slots 0 to 2,999 and the rest are free. User u_42 lands in express-pay in the checkout layer, in ranking-v2 in search, and in a free slot on the home page.layer: checkoutbutton-colorexpress-payfreelayer: searchranking-v2layer: home pagehero-copyfreemarked: where u_42 lands in each layer
Inside a layer, experiments never share a user, so two checkout tests can't collide. Across layers, a separate hash per layer shuffles users again, so u_42 can be in one experiment per layer at the same time.
ts
const checkoutLayer = {
  key: "layer-checkout",
  experiments: [
    { key: "button-color", slots: [0, 4_999] },   // 50% of the layer
    { key: "express-pay", slots: [5_000, 7_999] }, // 30% of the layer
    // slots 8,000 to 9,999 are free for the next test
  ],
};

function experimentInLayer(layer: typeof checkoutLayer, unitId: string) {
  const slot = bucket(`${layer.key}:${unitId}`);
  return layer.experiments.find((e) => slot >= e.slots[0] && slot <= e.slots[1]) ?? null;
}
Layer assignment: the layer hash picks a slot range, and only the experiment that owns that range runs for this user.

Layers cost traffic: exclusive tests each get only part of the layer. Use them for tests that touch the same element or decision, and let unrelated tests overlap.

Think first

Search has a ranking test in the backend and a new results card design in the frontend. Should they be in the same layer?

Server or client experiments, and the ethics of testing people

The flicker chapter showed one cost of deciding in the browser. This chapter covers the rest of the trade-off, then a part teams often forget: the people inside the test.

Comparing the two

QuestionServer-side (origin or edge)Client-side (browser SDK)
FlickerNone: the HTML already has the variantPossible unless values are bootstrapped
What it can testAnything: pricing, ranking, APIs, page structureUI the client renders, and behavior after load
Data for targetingFull: account, plan, historyOnly what the browser has or is sent
Exposure loggingReliable server events, but may log users who never saw the elementLogged at render, but lost if the page closes first
Bundle costOnly the chosen variant is sentThe SDK, and often both variants' code
SecrecyUnreleased features stay on the serverConfig and variant code are visible to anyone
Who can run testsEngineers, through codeProduct and marketing too, with visual tools
A flowchart for choosing where an experiment runs. Does it touch price or ranking? If yes, use the server SDK. If no: is it visible on the first paint? If yes, decide at the edge or with SSR. If no: does it appear after a user action? If yes, a client SDK with bootstrapped values is fine. If no, default to the edge or SSR.touches price or ranking?yesserver SDKnovisible on first paint?yesedge or SSRnoafter a user action?yesclient, bootstrappednoedge or SSR (default)
Server-side wins unless the change only exists after the page is interactive. Even then, bootstrap the assignments into the HTML so the client never has to wait for them.

Pitfalls specific to client experiments

  • Hydration mismatch. If the server renders control but the client SDK computes treatment, React throws away the server HTML or reports a hydration error. Pass the server's assignments to the client and never re-evaluate them during hydration.
  • Both variants in the bundle. A redesigned component imported statically ships to control users too. Load variant code with a dynamic import() only for the users who need it, and remove the losing branch quickly after the test.
  • A slower treatment for the wrong reason. If the treatment lazy-loads its code and control does not, the treatment is slower because of how it was built, not because of the idea. Make the cost equal, or measure it with the guardrails.
  • Leaks. Variant names in the client config can reveal unreleased plans, such as pricing-v3-annual-discount. Use opaque keys for sensitive tests and keep their logic on the server.

Assignment for logged-out users usually relies on an identifier stored in the browser. In the EU, storing or reading such an identifier for purposes that are not strictly necessary generally requires the user's consent under ePrivacy rules, and many regulators place A/B testing in that category. The UK has similar rules, recently relaxed for some analytics uses. The details differ by country and change over time, so treat this as a question for your legal team, not a guess.

  • Before consent, show control and don't store an id or count the user. Accept that results then describe consenting users only, who may differ from the rest.
  • Send the minimum to vendors: an opaque id and the attributes targeting needs, not email addresses or names.
  • Respect opt-outs everywhere: a user who declines analytics should not be bucketed and tracked through a different script.

Ethics: what not to test

Users don't know they are in an experiment, so the team carries the responsibility. A useful rule: only test variants you would be comfortable shipping to everyone. Hiding the cancel button, fake urgency, or different prices for the same product can raise legal questions and break trust.

In 2014, a published Facebook study that adjusted the emotional tone of news feeds drew wide criticism because people felt manipulated. Tests that touch money, health, minors, or emotions deserve review by someone outside the team. And both variants must meet your accessibility standards.

Check your understanding · Exercise 7

Which experiments should be decided on the server or edge rather than in the browser? Select all that apply.

In real products and in the system design interview

Companies that run experiments at scale have published a lot about it. Microsoft, Booking.com, Netflix, Airbnb, and LinkedIn each describe running thousands of experiments a year on in-house platforms, with automatic SRM checks, guardrail dashboards, and layers.

A widely cited example comes from Microsoft's Bing in 2012. A small change to how ad headlines were displayed sat in the backlog for more than six months; when finally tested, it raised revenue by about 12%. The lesson the authors draw is humbling: most ideas, even ones experts are sure of, do not improve the metric they target.

When to bring it up

Bring up experimentation when the prompt involves growth, conversion, ranking, or a redesign of a high-traffic page: a checkout, a feed, a search results page, a landing page. One or two sentences are enough at first: "We'd ship this behind an experiment flag and measure orders per user, with LCP and error rate as guardrails." Go deeper if the interviewer follows up.

What to draw

  1. An experiment config service that publishes definitions to SDKs and the edge.
  2. Assignment at the edge or server: a hash of experiment key and unit id, sticky through a server-set cookie.
  3. Rendering of the variant on the server, with assignments inlined for hydration.
  4. An exposure event from the client when the element is visible, sent with sendBeacon.
  5. An event pipeline into a warehouse, where the analysis joins exposures with metric events per user.
  6. A results page with the SRM check first, then the primary metric with its interval, then guardrails.
LevelWhat a strong answer covers
JuniorRandom 50/50 split, control and treatment, one metric, run long enough, don't ship on a lucky day
MidHash-based sticky bucketing, flicker and how SSR or the edge avoids it, exposure events, guardrails like LCP and error rate, sample size from an MDE
SeniorSRM checks and their frontend causes, identity stitching, triggered analysis, peeking and sequential tests, layers for interactions, cache keys per variant, consent, and the cost of anti-flicker snippets on LCP

Check your understanding · Exercise 8

An interviewer asks you to design A/B testing for a statically generated, CDN-cached landing page. Which points belong in a strong answer? Select all that apply.

Summary

  • An A/B test compares groups at the same time, chosen at random, so the only systematic difference between them is the change.
  • Assign with a deterministic hash of the experiment key and a stable id, with separate hashes for allocation and variant, so users stay sticky and ramps never move anyone.
  • Log exposure where the difference becomes visible, and analyze only exposed users at the same unit you randomized.
  • Check the sample ratio before any metric; a mismatch usually means the frontend lost users from one group.
  • Decide variants on the server or at the edge to avoid flicker; anti-flicker snippets trade flicker for a slower LCP on all traffic.
  • Pick one primary metric in advance, and guard performance, reliability, and business health with guardrail metrics.
  • Size the test from the minimum detectable effect, run whole weeks, and don't stop at the first significant day unless you use sequential statistics.
  • Use layers to keep tests on the same surface apart, respect consent, and only test variants you would be willing to ship.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

How do you assign users to variants so they always see the same one?

Question 2

A treatment shows a 3% lift, but the groups have 50.6% and 49.4% of users on a 50/50 split with 200,000 users. What do you do?

Question 3

How do you avoid flicker in an experiment on a cached landing page?

Question 4

How long should an experiment run?

Question 5

What guardrail metrics would you add for a frontend experiment, and why?

Practice it

These design problems lean on a/b testing.

References

Read next