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

StoreConsistencyLatencyUse it for
Edge KVEventually consistent, fast readsVery lowFlags, experiment assignments, cached fragments
Regional KVStronger consistencyLowSession data and counters
Origin databaseStrongHigh on the request pathAnything transactional
CookieClient-held, tamperable if unsignedNoneAn assignment id, signed
Signed tokenStateless, verifiable in the functionNoneEntitlements 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;
}
  1. Read flags with a timeout and a default. An unreachable KV store must not take the site down.
  2. Cache the flag value in the function instance for a few seconds; a per-request read on every request is unnecessary load.
  3. 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.
  4. 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
}
RequirementWhyHow
Stable assignmentA user flipping variants invalidates the testHash an identifier stored in a cookie
Consistent experienceThe same user must see the same versionAssign once, store, respect the stored value
MeasurableThe result must be attributableRecord the variant on every event
No cache pollutionThe variant must not become part of the cache keyCache the shared shell, vary only the fragment
Kill switchA broken variant must be disableable in secondsA flag that forces everyone to the control
Enough trafficA test that cannot conclude is a wasteEstimate the sample size before starting
One test at a time per areaOverlapping tests confound each otherCoordinate 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.

Edge functions, redirects and rewrites Cache headers and cache keys

Last refreshed 2026-09-18.