proxy — A/B bucketing

The proxy assigns an experiment-bucket cookie on first visit — control</C> or <C>variant — and leaves it alone afterwards. The panels below render that bucket three different ways, all of them correct.

The interesting number is the cache cost. Bucketing multiplies cache entries: two buckets means two entries for the same component, each with its own fill and its own revalidation schedule.

What to check

See your bucket. The response header x-proxy-bucket reports it, and x-proxy-bucket-assigned says whether this request is the one that assigned it:

curl -sI localhost:3000/proxy/experiment | grep -i x-proxy-bucket

Reassign it. Load /proxy/experiment?reset=1, then reload. The proxy clears both cookies and rolls again, so a few rounds will land you in the other bucket.

Count the entries. Note the cached panel’s execution id for your bucket. Reset until you flip, and it changes. Reset back, and the original id returns — the first bucket’s entry was still there. That is two live entries for one component.

Force both buckets from the shell without touching cookies in the browser:

for b in control variant; do
  echo "bucket=$b"
  curl -s localhost:3000/proxy/experiment -H "cookie: experiment-bucket=$b" \
    | grep -o 'data-panel="Cached by bucket"[^>]*data-exec="[^"]*"'
done

Two different ids, each stable across repeat calls. Then compare the private panel — it differs per client and is never written to a server cache at all.

Your assignment

Bucket from the proxy

suspense fallback

streaming at request time — this is what ships in the static shell

Three correct ways to render a bucket

Reading the cookie at request time and passing the value into a cached scope, caching per-client instead, or not caching at all. All three respect the visitor’s bucket; they differ in what they cost and who they are shared with.

Cached by bucket

suspense fallback

one entry per bucket

Private cache

suspense fallback

streaming at request time — this is what ships in the static shell

Uncached

suspense fallback

streaming at request time — this is what ships in the static shell

Why the classic bug will not compile

The bug you are trying to reproduce looks like this, and it is rejected:

async function Headline() {
  'use cache'
  cacheLife('max')
  const bucket = (await cookies()).get('experiment-bucket')?.value
  return <h1>{bucket === 'variant' ? 'B' : 'A'}</h1>
}

Verified against this project — the build fails with:

Error: Route /tmp-verify used `cookies()` inside "use cache".
Accessing Dynamic data sources inside a cache scope is not supported.
If you need this data inside a cached function use `cookies()` outside
of the cached function and pass the required dynamic data in as an argument.

A cached scope cannot read cookies(), headers() or searchParams, and the restriction follows the call stack — a helper that reads one fails the same way. On a dynamically rendered route this can surface at runtime rather than at build, so it may pass next build and fail under next start.

Reading the cookie outside and passing it in, as getBucketedValue(bucket) does, puts the bucket in the cache key by construction. The framework makes the correct shape the only available one.