Assigning a Stable Experiment Bucket Before First Paint
Pricing page, two layouts, 50/50 split. The naive implementation assigns the bucket in a Client Component with useState(() => Math.random() < 0.5). Three things go wrong: the server's first paint shows variant A and hydration snaps it to B (a visible flash plus a hydration warning), the bucket can change on every mount, and analytics logs two exposures for one user. An experiment bucket must be assigned once, on the server, before the first byte of HTML exists — which is exactly what middleware lets you do.
// proxy.ts (middleware.ts on Next.js ≤ 15)
import { NextResponse, type NextRequest } from 'next/server'
const COOKIE = 'ab-pricing-v1'
const PERCENT_B = 50
export function proxy(request: NextRequest) {
const res = NextResponse.next()
let variant = request.cookies.get(COOKIE)?.value
if (variant !== 'a' && variant !== 'b') {
const roll = crypto.getRandomValues(new Uint8Array(1))[0] // 0–255, edge-safe Web Crypto
variant = roll < Math.floor((PERCENT_B / 100) * 256) ? 'b' : 'a'
res.cookies.set(COOKIE, variant, {
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 days: long enough to survive the whole funnel
sameSite: 'lax',
})
}
return res
}
Why each choice matters:
- The cookie makes the bucket durable. Serverless instances are disposable — memory is wiped on every cold start, and a request in Frankfurt must agree with one in Virginia. A value in a
Mapor a module-level variable is not an assignment; a cookie on the request is.crypto.getRandomValuescomes from Web Crypto, which the edge runtime provides (see Edge vs Node Runtimes), so no Node-only PRNG sneaks in. - Version the cookie name. When you redesign the experiment,
ab-pricing-v1→ab-pricing-v2resets every bucket cleanly instead of inheriting stale assignments. - Weight with a threshold, not with repeated rolls. One roll per assignment;
roll < 128gives 50%,roll < 26gives ~10%. Rolling until a condition is met biases nothing but wastes nothing either — the point is the bucket is written down, not recomputed.
The alternative is deterministic bucketing: hash a stable identifier instead of rolling. hash(userId) % 100 < 50 reproduces the same bucket on every device for a logged-in user and needs no cookie at all:
| Strategy | Pros | Cons |
|---|---|---|
| Random roll + cookie | works pre-login, simple, survives device loss | new assignment per device/browser |
| Hash of user id | consistent across devices, auditable | unavailable for anonymous visitors, rehash needed when cohort changes |
| Hash of user id + stored assignment | consistent and stable when weights change | needs a write to your database |
Production experiments usually combine them: hash for authenticated users, cookie for everyone else, with the cookie winning on the request so a pre-login assignment carries into the session.
Two details finish the job. Validate what you read back: a tampered or stale cookie ('c', an empty string, an old experiment's value) must fall through to a fresh roll rather than be trusted — display-only experiments can tolerate a user editing document.cookie, but anything that drives a discount or an entitlement must be re-derived on the server from a verified identity, never from a cookie the client owns. sameSite: 'lax' keeps the cookie off cross-site POSTs without breaking top-level navigation, and unless a client-side script must read the bucket, leave httpOnly on and log exposures server-side instead — a bucket readable by any injected script is a bucket an attacker can corrupt. And the assignment is only half the mechanism: something downstream must render from that value. That handoff happens through headers, which is the next chapter.
Handing the Variant to the Render: Request Headers vs Response Headers
The bucket exists in middleware. The page that renders it is a Server Component in app/pricing/page.tsx. They run in different phases of the same request, so you need a channel between them — and the single most common mistake is using the wrong direction of headers.
NextResponse.next({ request: { headers } }) sets request headers that flow upstream into your render. response.headers.set(...) sets headers that flow downstream to the browser. They are not interchangeable, and using the second when you meant the first silently produces a page that reads null.
// proxy.ts — forward the decision into the render
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-ab-variant', variant)
requestHeaders.set('x-ab-exp', 'pricing-v1')
const res = NextResponse.next({ request: { headers: requestHeaders } })
res.headers.set('x-ab-variant', variant) // response header: visible to the browser and to CDN logs
return res
The Server Component then reads it with headers() from next/headers:
// app/pricing/page.tsx
import { headers } from 'next/headers'
import { AnnualTable } from './annual-table'
import { MonthlyTable } from './monthly-table'
export default async function PricingPage() {
const variant = (await headers()).get('x-ab-variant') // async: sync access was removed in Next.js 16
return variant === 'b' ? <AnnualTable /> : <MonthlyTable />
}
Three properties of this mechanism are worth internalizing:
- Reading
headers()opts the route into request-time rendering. The page can no longer be served from a purely static render — the value differs per request. That is inherent to personalization; just do not accidentally read headers on a route that must stay static, or you have traded cache hits for a variant nobody varies. - Client Components cannot call
headers(). The server parent reads the value and passes it as a prop, or the client reads the cookie you already set. Server is the source of truth; the cookie is the durable copy. NextRequestandNextResponsedo different jobs.NextRequest.headersis what the client sent (plus what you added on the previous hop);NextResponseis what you are about to send —res.cookies.set()writesSet-Cookiefor the browser, while therequestoption writes into the headers the router and your pages will see.
One question that settles interviewers: does the RSC payload request get the same variant as the document request? It does, because middleware runs for both — the document load and every subsequent <Link> navigation re-enter your function, read the cookie written on the first response, and forward the identical header. The HTML, the RSC payload, and the client cache therefore agree without any coordination code.
Known sharp edges, all documented:
- Do not stuff payloads into headers. Oversized headers earn
431 Request Header Fields Too Largefrom the proxy or CDN — hash or reference the value, never inline JSON blobs. - Internal RSC headers are stripped. During RSC requests Next.js removes
rsc,next-router-state-tree, andnext-router-prefetchfromrequest.headersso the HTML request and the RSC request cannot be handled differently by accident — do not branch on them.NextResponse.rewrite()re-propagates what it needs automatically. - Set an observability header.
x-ab-variantas a response header means acurl -I, a support screenshot, or a CDN log line shows which variant served — the cheapest way to debug "user X saw the wrong pricing".
For diagnostics beyond headers, event.waitUntil(fetch('/api/exposures', ...)) records the exposure without delaying the response, and it keeps the count on the same request that rendered the variant — so an exposure log and a page view can never disagree.
The decision now reaches the render on every matched request. What remains is what this does to everything around the render: caches, crawlers, and prefetch traffic.
Varying by Geography, Headers, and Cookies Without Destroying Your Cache
Varying a response before it renders is powerful in three directions — geography, request headers, and cookies — and each one quietly changes who can cache the result.
export function proxy(request: NextRequest) {
const country =
request.headers.get('x-vercel-ip-country') ?? // Vercel
request.headers.get('cf-ipcountry') ?? // Cloudflare
'US'
const lang = request.headers.get('accept-language')?.slice(0, 2) ?? 'en'
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-country', country)
requestHeaders.set('x-lang', lang)
return NextResponse.next({ request: { headers: requestHeaders } })
}
(Platform-agnostic note: on Vercel the value also arrives as request.geo.country. Reading platform headers keeps the same code portable — one more reason shared middleware sticks to Web APIs.)
Decide what varies before deciding how, because geography usually wants one of two shapes. Same URL, different content — currency symbols, legal copy, availability — is a header decision: forward x-country and let the render pick, keeping one canonical URL and one cache entry per bounded variant. Different URL per locale — /de/preise — is a routing decision, and the redirect belongs in next.config.js for a static mapping or in middleware when the target depends on accept-language negotiation (Redirects and Rewrites covers why a 307/308 versus a rewrite changes what search engines index). Mixing the two — rewriting internally while also redirecting — is how teams end up with two indexed URLs for one page.
Before you vary everything, understand the cost model:
1. Cache fragmentation is the real bill. A CDN caches by URL plus the headers a response declares in Vary. Set Vary: Cookie and effectively every distinct cookie value becomes its own cache entry — hit rate collapses toward zero. The discipline: bound the number of variants. Two to five experiment buckets are cacheable; a per-user variant is not. If a response must vary per user, it belongs in dynamic rendering behind middleware, not in a shared cache — and you should say so out loud rather than discovering it in the CDN bill.
2. Prefetch traffic doubles your work. <Link> prefetches fire requests with next-router-prefetch and purpose: prefetch headers. Personalizing them assigns buckets nobody will see and multiplies cache keys. The documented matcher shape skips them:
export const config = {
matcher: [{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
}],
}
3. Crawlers are just another client — with consequences. A bot with no cookie gets a random bucket, so Googlebot may index variant A while your users see B. For experiments that change pricing tables or headings, that is a cloaking risk and an SEO headache; keep experiments cosmetic, or make the assignment deterministic for known crawlers, and always keep canonical/OG metadata identical across variants.
4. One exposure, logged once, on the request that rendered it. Client-side analytics re-fire on hydration, SPA navigation, and double-mounted effects. Logging experiment, variant, country server-side via event.waitUntil ties the number to the render you actually served — the same principle as forwarding the variant header: the server knows, so make the server say it.
Verification is three commands, not a dashboard. curl -I twice without a cookie and confirm both responses carry Set-Cookie with the same bucket on the second request; replay with -H 'Cookie: ab-pricing-v1=b' and confirm the markup matches variant B; and use unstable_doesProxyMatch({ config, nextConfig, url }) from next/experimental/testing/server to assert the matcher keeps static assets and prefetches out of the experiment.
The same file that personalizes a page can also decide whether a request deserves an answer at all — counting requests per client before any work happens, in Throttling Abuse at the Edge.
Quiz
1. An experiment bucket is stored in a module-level `Map` inside middleware. Why is this wrong on a production deployment?
2. You need a Server Component to read the variant assigned in middleware. Which call produces it?
3. What is the main caching cost of setting `Vary: Cookie` on a personalized page?
4. A `<Link>` prefetch request arrives and middleware assigns it a fresh bucket before the user ever navigates. How do you stop that?
Flashcards
Question
How do you assign a stable A/B bucket in middleware, and why not in memory or client-side JS?
Click to reveal answer
Answer
Read the versioned cookie (`ab-pricing-v1`); if absent, roll `crypto.getRandomValues(...)` against a threshold and write it with `res.cookies.set(name, value, { path: '/', maxAge, sameSite: 'lax' })`. Memory fails because instances are disposable and requests hop regions; client-side assignment causes a hydration flash, unstable buckets, and double-counted exposures.
Question
Request headers vs response headers in middleware — which direction does each flow?
Click to reveal answer
Answer
`NextResponse.next({ request: { headers } })` injects headers upstream so router, pages, and handlers see them (read with `await headers()` from next/headers). `res.headers.set()` / `res.cookies.set()` go downstream to the browser and CDN. Using the downstream form when you meant the upstream one is the classic bug: the page reads null.
Question
What does reading `headers()` cost, and what can't read it?
Click to reveal answer
Answer
`headers()` is async (sync access removed in Next.js 16) and reading it opts the route into request-time rendering — it can no longer be served from a purely static render. Client Components cannot call it; the Server Component reads the value and passes it as a prop, or the client reads the cookie you already set.
Question
How does edge personalization damage caching, and what is the discipline?
Click to reveal answer
Answer
`Vary: Cookie` (or any varying header) splits the CDN key per value, so per-user variation collapses hit rate. Keep the variant count bounded (2–5 experiment buckets), keep per-user work in dynamic rendering behind middleware, and exclude prefetch traffic from personalization via matcher `missing` entries.
Question
How do you verify an edge experiment end to end?
Click to reveal answer
Answer
Two `curl -I` calls without a cookie must return the same `Set-Cookie` bucket on the second; a replay with `-H 'Cookie: ab-pricing-v1=b'` must render variant B's markup; `unstable_doesProxyMatch({ config, nextConfig, url })` from next/experimental/testing/server asserts the matcher; and the response header `x-ab-variant` makes any single request's variant visible in logs.
Revision Notes
Key Takeaways
- 1. Assign buckets server-side in middleware with a versioned cookie — durable across cold starts and regions, assigned before first paint, so there is no hydration flash
- 2. Forward the decision upstream with `NextResponse.next({ request: { headers } })` and read it via `await headers()`; response headers go to the browser and are for observability, not for your pages
- 3. Geography and language vary cleanly from platform headers (x-vercel-ip-country, cf-ipcountry, accept-language), but every varying input multiplies cache keys
- 4. Bound the variant count (2–5 buckets), skip prefetch traffic in the matcher, and keep crawlers seeing consistent canonical content to avoid cloaking issues
- 5. Log exposures server-side with event.waitUntil on the request that rendered the variant, and make the variant visible as a response header for debugging
Interview Tips
- • "How would you run an A/B test without a flash of the wrong variant?" — assign in middleware, persist in a cookie, forward via request headers, render from `headers()`; explicitly contrast with client-side Math.random and explain hydration mismatch
- • "Where do you store the bucket?" — cookie vs hash(userId) vs stored assignment, with trade-offs (anonymous users, cross-device consistency, weight changes), plus why module-scope Maps fail on serverless
- • "Your personalization destroyed CDN hit rate — why?" — Vary/Cookie key multiplication; fixes: bounded buckets, dynamic rendering for per-user work, matcher exclusions for prefetch
- • "How do you debug 'user X saw the wrong variant'?" — response header `x-ab-variant`, curl replay with their cookie, matcher assertions with unstable_doesProxyMatch, and server-side exposure logs tied to the render
Cheat Sheet
A/B Tests and Personalization at the Edge
Assign the bucket in middleware (before first paint)
const COOKIE = 'ab-pricing-v1' // version the name to reset cohorts
const res = NextResponse.next()
let variant = request.cookies.get(COOKIE)?.value
if (variant !== 'a' && variant !== 'b') {
const roll = crypto.getRandomValues(new Uint8Array(1))[0] // Web Crypto — edge-safe
variant = roll < 128 ? 'b' : 'a'
res.cookies.set(COOKIE, variant, { path: '/', maxAge: 2592000, sameSite: 'lax' })
}
| Strategy | Use when |
|---|---|
| Random roll + cookie | anonymous traffic; survives cold starts and regions |
hash(userId) % 100 |
logged-in users; consistent across devices, no cookie |
| Hash + stored assignment | weights may change after assignment |
Forward the decision — direction matters
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-ab-variant', variant) // upstream → your render
const res = NextResponse.next({ request: { headers: requestHeaders } })
res.headers.set('x-ab-variant', variant) // downstream → browser/CDN logs
// page.tsx — async in Next.js 16 (sync access removed)
const variant = (await headers()).get('x-ab-variant') // from 'next/headers'
request.headersoption → visible to pages/handlers.res.headers.set→ sent to the client. Not interchangeable.- Reading
headers()makes the route dynamic; Client Components can't read it — pass a prop. res.cookies.set()→Set-Cookiefor the browser (durable copy of the bucket).
Varying inputs
| Source | Read it from |
|---|---|
| Country | request.geo.country (Vercel) or x-vercel-ip-country / cf-ipcountry |
| Language | accept-language |
| Experiment bucket | cookie → request header → page |
Pitfalls
- Cache fragmentation:
Vary: Cookiegives every cookie value its own cache entry. Keep variants bounded (2–5); per-user variation belongs in dynamic rendering, not the CDN. - Prefetch waste: exclude prefetch traffic with
missing: [{ type: 'header', key: 'next-router-prefetch' }, { type: 'header', key: 'purpose', value: 'prefetch' }]. - Crawlers get a random bucket — keep experiments cosmetic or deterministic for bots; identical canonical/OG across variants.
- Double-counted exposures — log server-side with
event.waitUntilon the request that rendered the variant. - 431 Request Header Fields Too Large — never inline payloads in headers.
- RSC headers stripped:
rsc,next-router-state-tree,next-router-prefetchare removed fromrequest.headers; don't branch on them.
Verify: curl -I twice (same Set-Cookie bucket), replay with -H 'Cookie: ab-pricing-v1=b', and assert matcher behavior with unstable_doesProxyMatch from next/experimental/testing/server.