Edge architecture: personalisation and A/B testing at the edge
Splitting a cached shell from personalised fragments, edge key-value stores, experiment assignment at the edge, and keeping cache keys consistent for personalised responses.
Cached shell plus fragments
A page cannot be both cached for everyone and personalised for one person. The solution is structural: cache the parts that are identical and fetch the parts that are not, after the page has already rendered.
<!-- the shell is cached at the edge for everyone -->
<main>
<h1>Your recommendations</h1>
<!-- the fragment is fetched separately, with the user's credentials -->
<div id="recs"
hx-get="/api/recommendations"
hx-trigger="load"
hx-swap="innerHTML">
<p class="skeleton">Loading recommendations...</p>
</div>
</main># the shell: cacheable, shared, fast
Cache-Control: public, s-maxage=300, stale-while-revalidate=86400
# the fragment: never shared, short
Cache-Control: private, no-store- Reserve the fragment's space with a skeleton so filling it does not shift the layout.
- The fragment request happens after the first paint, so it must be cheap. If it needs a slow query, cache it per user for a short time in a KV store.
- The shell must be identical for everyone, including the language and the currency. Anything that varies belongs in the fragment or in the cache key.
Edge state
| Store | Consistency | Latency | Use it for |
|---|---|---|---|
| Edge KV | Eventually consistent, fast reads | Very low | Flags, experiment assignments, cached fragments |
| Regional KV | Stronger consistency | Low | Session data and counters |
| Origin database | Strong | High on the request path | Anything transactional |
| Cookie | Client-held, tamperable if unsigned | None | An assignment id, signed |
| Signed token | Stateless, verifiable in the function | None | Entitlements with an expiry |
// read a flag from the edge KV once per request, with a default
export default async function handler(request, env) {
let flags = { newCheckout: false };
try {
flags = (await env.FLAGS.get("checkout", "json")) ?? flags;
} catch (err) {
// never fail the request because the flag store is unreachable
console.error("flag read failed", err);
}
const response = await fetch(request);
if (flags.newCheckout) {
const html = (await response.text()).replace("checkout-v1", "checkout-v2");
return new Response(html, { headers: response.headers });
}
return response;
}- Read flags with a timeout and a default. An unreachable KV store must not take the site down.
- Cache the flag value in the function instance for a few seconds; a per-request read on every request is unnecessary load.
- Treat the KV store as a cache, not as a database. It is eventually consistent, so it cannot be the source of truth for anything transactional.
- If the response is cached, changing the body in the function means the cached copy no longer matches what is served. Change the origin output, or split the fragment.
Experiments at the edge
// assignment must be stable, and the cache key must not include it
async function variantFor(request, env) {
const cookie = parseCookies(request.headers.get("cookie") ?? "");
if (cookie.exp_assignment) {
const [name, variant] = cookie.exp_assignment.split(":");
if (name === "pricing_layout") return variant;
}
const id = cookie.visitor_id ?? crypto.randomUUID();
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode("pricing_layout:" + id));
const bucket = new Uint8Array(digest)[0] % 100;
return bucket < 20 ? "b" : "a"; // 20 percent to the variant
}| Requirement | Why | How |
|---|---|---|
| Stable assignment | A user flipping variants invalidates the test | Hash an identifier stored in a cookie |
| Consistent experience | The same user must see the same version | Assign once, store, respect the stored value |
| Measurable | The result must be attributable | Record the variant on every event |
| No cache pollution | The variant must not become part of the cache key | Cache the shared shell, vary only the fragment |
| Kill switch | A broken variant must be disableable in seconds | A flag that forces everyone to the control |
| Enough traffic | A test that cannot conclude is a waste | Estimate the sample size before starting |
| One test at a time per area | Overlapping tests confound each other | Coordinate the test calendar |
⚠️
Personalising a cached response without adding the personalising input to the cache key is how one user's data ends up served to another. If the response differs by user, it must be private and uncached - or it must be assembled client side from a shared shell and a private fragment.
FAQ
Can I personalise a fully cached page?
Not safely. Cache the shared parts, fetch the per-user parts separately, and never let a cache key miss an input that changes the response.
Where should experiment assignment live?
At the edge when the variant affects the first paint; in the application when it only affects behaviour after load. Edge assignment is later in the request path and needs a stable identifier and a flag store.
Related
Edge functions, redirects and rewrites Cache headers and cache keys
Last refreshed 2026-09-18.