Proxy A/B bucketing

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

The number to watch is the cache cost. Two buckets means two cache 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, because 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 re-executes on every request, because it is never written to a server cache.

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 cost and in who shares the result.

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>
}

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, so a helper that reads one fails the same way. On a dynamically rendered route this can show up at runtime instead of 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. The framework leaves no other way to write it.