×marble
all posts
Jun 21, 2026·12 min read

Cloudflare Workers + KV for Edge Personalization: Sub-20ms Cohort Routing

Cloudflare Workers + KV give you sub-20ms cohort routing at the edge, globally. The patterns, the gotchas, and how it composes with the origin.

Alex Shrestha·Founder, ×marble

Cloudflare Workers + KV for Edge Personalization: Sub-20ms Cohort Routing

TL;DR.

  • Cloudflare Workers + KV for edge personalization gives you a key-value store that reads in single-digit milliseconds from any of Cloudflare's PoPs, which is what makes sub-20ms cohort routing realistic.
  • Workers KV is eventually consistent: writes propagate globally within roughly 60 seconds. That's fine for cohort assignments, A/B variants, and feature flags. It is not fine for "the user just clicked, show this next."
  • The per-value cap is 25 MiB and keys are limited to 512 bytes, so you store cohort IDs and lookup pointers at the edge, not full user profiles.
  • For per-user, write-heavy state (recent reactions, session events) use Durable Objects. For relational personalization data you query, use D1. KV is the routing layer.
  • The pattern that scales: edge Worker resolves a cohort from a cookie or geo header, looks up a routing/variant config in KV, and either rewrites the request or calls a personalization API at the origin. Origin is hit only when KV can't answer.

If you're personalizing a marketing page or a logged-out homepage and your first byte is leaving the user's continent, you've already lost. The CDN tier of your stack is the only place where "personalized" and "fast" can both be true. This post is about doing that with Cloudflare Workers and Workers KV — the patterns that hold up in production, the limits that bite, and where you should reach for Durable Objects or D1 instead.

Why the edge is the only honest place to personalize a fold

The math is simple. A user in Sao Paulo hitting an origin in us-east-1 is paying around 110-150 ms in network round-trip before your application server has even parsed the request. Add a TLS handshake on a cold connection and you're at 250 ms before any byte of HTML is generated. Personalize that response server-side and you've added another database call. By the time the page renders, the user has read your tab title and gone back to their feed.

Cloudflare Workers run in roughly 330 PoPs globally — close to every meaningful population center. When you push personalization logic into a Worker, the request never leaves the user's region. The whole personalization decision — cookie parse, cohort lookup, variant selection, response rewrite — can complete in under 20 ms p50 if you keep the data path inside Workers KV.

This is why "edge personalization" is not just a deployment trick. It changes what's possible. You can personalize the hero, the headline, the CTA, the social proof, and the inline pricing on a marketing page without adding any user-perceived latency. The alternative — calling an origin personalization API and waiting for it to respond before flushing HTML — typically adds 200-500 ms to first byte. We've written about why this layer needs to be separate from your recommendation engine in recommendation engine vs personalization layer; Workers KV is what makes the personalization layer fast.

Workers KV is a read-optimized store with a consistency budget

You have to design around Workers KV's trade-offs or it will burn you. Three numbers matter.

Eventual consistency, ~60 seconds. Writes to a key are propagated globally but reads from a different PoP may see the old value for up to roughly 60 seconds. This is by design — KV is built for read-heavy workloads where staleness is acceptable. For cohort assignments and A/B variant configs this is fine. For "the user just logged in, show their dashboard" it is not.

Value size cap is 25 MiB per key, keys are capped at 512 bytes (Cloudflare KV limits). In practice you'll keep values much smaller. The median object size across all of Cloudflare's KV traffic is around 288 bytes (InfoQ on the 2025 rearchitecture). For personalization you're typically storing a cohort ID, a variant assignment, or a small config blob — well under 1 KB.

Free tier: 100,000 reads per day, 1,000 writes per day, 1 GB stored. Paid plan: 10M reads, 1M writes, 1 GB storage included monthly, then $0.50 per million reads, $5.00 per million writes, $0.50 per GB-month (Workers pricing). For Cloudflare Workers KV personalization, reads dominate by orders of magnitude — every page view is a read, but cohort assignments only flip on user events.

There's a less-known limit that hits cohort routing hard: you can only write to the same key once per second. If you're attempting to store "last seen" per user in KV, you're doing it wrong. Per-user write-heavy state belongs in Durable Objects, where each object has strongly consistent storage and is single-threaded.

Cloudflare rearchitected KV in 2025 after a GCP outage: small objects now live in their own distributed database and large objects in R2, and they reported a 40x p99 latency improvement, with the new backend serving p99 reads under 5 ms (InfoQ). That's the number you can build against today.

The cohort routing pattern: cookie → KV → variant

This is the pattern we've shipped most. The Worker runs on every request to a personalizable URL. It resolves a cohort, looks up the variant config, and either rewrites the response or fetches a personalized fragment from the origin.

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    // 1. Resolve cohort from cookie or geo header
    const cohort = resolveCohort(request);

    // 2. Look up variant config in KV
    const variantKey = `variant:${url.pathname}:${cohort}`;
    const variant = await env.PERSONALIZATION.get(variantKey, { type: 'json' });

    // 3. Fetch the origin response
    const response = await fetch(request);

    if (!variant) return response;

    // 4. Rewrite hero, headline, CTA based on variant
    return new HTMLRewriter()
      .on('[data-personalize=hero]', new HeroSwap(variant.hero))
      .on('[data-personalize=cta]', new CtaSwap(variant.cta))
      .transform(response);
  },
};

function resolveCohort(request) {
  const cookie = request.headers.get('Cookie') ?? '';
  const match = cookie.match(/cohort=([^;]+)/);
  if (match) return match[1];

  // First-touch: derive from geo + UTM
  const cf = request.cf ?? {};
  const country = cf.country ?? 'XX';
  const utm = new URL(request.url).searchParams.get('utm_campaign') ?? 'organic';
  return `${country}:${utm}`;
}

A few things worth pointing out here.

Cohorts come from cookies first, geo + UTM second. First-touch users have no cohort cookie. We derive one from request.cf (Cloudflare's enriched request object) and the campaign UTM. Once they're on the page, JS sets the cookie. Day-zero personalization is a real engineering problem — we go deep on it in the cold-start problem and day-zero personalization.

KV is keyed by {page}:{cohort}, not by user ID. This is the design choice that keeps writes infrequent. Cohort definitions update when marketing decides to test a new variant — a few times per day per page at most. User-level state lives elsewhere.

HTMLRewriter runs in streaming mode. You don't buffer the origin response. The Worker reads tokens as they stream in, swaps tagged elements, and pipes the rest through unchanged. This keeps the Worker itself well under the p50 target.

KV vs D1 vs Durable Objects for personalization state

People reach for KV for everything, then get burned. Here's the decision table we use:

| Data | Best storage | Why | |---|---|---| | Cohort definitions (page × variant) | Workers KV | Read-heavy, write-rare, eventual consistency fine | | A/B variant configs | Workers KV | Same shape as cohorts | | Feature flags (short TTL) | Workers KV | TTL handles staleness; 60s window is acceptable | | Per-user session state | Durable Objects | Strong consistency per object; supports writes-per-second per user | | Recent user events (reactions, clicks) | Durable Objects | Per-user write throughput, no contention | | Relational personalization data | D1 | SQL queries, joins, ad-hoc analytics | | Large content variants | R2 + KV pointer | KV stores the pointer, R2 stores the bytes |

The Cloudflare docs make this distinction directly: KV is for "configuration data, service routing metadata, personalization, and session storage that require high read volumes," while Durable Objects are for "per-user or per-customer SQL state" (storage options). D1 fits the niche where you actually want SQL semantics — analytics on cohort behavior, joining user profile tables, the things you'd do in Postgres if Postgres ran at the edge.

If you're building a knowledge-graph-driven personalization layer rather than just a routing layer, KV is the wrong tool for the graph itself. The graph lives at the origin, KV stores the routing decisions the graph produces. This is the architectural seam we describe in the reference architecture for real-time personalization.

A/B testing at the edge: the design that scales

A/B at the edge with Workers KV is the cleanest implementation of split testing we've ever shipped. The pattern:

  1. Marketing creates an experiment in your admin tool. The tool writes experiment:{slug} to KV with the variant weights and content blobs.
  2. The Worker reads experiment:{slug} on every request to the experiment URL. Within 60 seconds of any update, every PoP serves the new variant.
  3. The Worker assigns each user to a variant deterministically from a cookie hash (so the same user keeps the same variant) and writes the assignment to a bucket=A cookie.
  4. The page emits the variant ID in a data attribute, your analytics tool reads it, and conversion attribution is straightforward.

The thing this gets right that traditional A/B platforms get wrong: the variant decision is made before any HTML is sent, not by client-side JS that flashes the original content first. No flicker, no SEO penalty, no waiting on a third-party script. Cloudflare's own docs walk through a similar split-testing pattern that uses KV as the experiment registry (routing with Workers KV).

For multi-arm bandits (where weights shift over time as winning variants get more traffic), KV's eventual consistency works in your favor — you don't need every PoP to agree on the exact weight at the same instant. A last_updated timestamp plus a short TTL keeps drift bounded.

How edge personalization composes with the origin

The mistake we see most often: teams try to do everything at the edge. That's not what the edge is for. The pattern that holds up:

  • Edge handles routing decisions, variant selection, fragment caching, and small response rewrites. Workers + KV. Sub-20ms.
  • Origin handles the personalization model — the knowledge graph, the recommender, the ranking layer. Whatever you run there: a Python service, a vector store, a custom engine. Origin response time can be 100-300 ms, because the edge only calls it when KV can't answer.
  • Async event ingestion writes back to the origin from the Worker. Reactions, clicks, scroll-depth events are POSTed asynchronously (you can use event.waitUntil(...) in the Worker so the user doesn't pay for it). The origin updates the model; the model writes new cohort assignments back to KV.

The Worker is a fast lookup layer. The origin is the system of record. KV is the bridge. When the model decides "user 1234 just crossed the threshold from cohort A to cohort B," the origin writes user:1234:cohort = B to KV, and within 60 seconds every PoP serves the new cohort.

This composition is also why edge personalization isn't a replacement for a real personalization layer — it's the delivery mechanism. We get into that distinction more in the marketing engineer's personalization stack and five patterns for adding personalization to your app.

Common gotchas we've hit in production

Three things will burn you:

  1. Cold KV reads on rarely-accessed keys. A key that hasn't been touched in a region falls out of the local cache and the next read is a back-haul. For a personalization site with global, fairly even traffic this is rare. For a regional site it matters — consider periodic warming or shorter TTLs combined with background fetches.
  2. Writes-per-second-per-key throttling at 1/s. If your admin tool batch-updates 10,000 cohorts in a loop, you'll hit this. Use the bulk write API or chunk writes by key.
  3. Stale cohort assignments during cohort churn. If a user is moved between cohorts, the old assignment can be served for up to 60 seconds. For most personalization this is fine. For "the user just paid for premium, do not show the upgrade nudge anymore" it isn't — use a cookie override for transactional cases.

How ×marble fits in

Workers + KV gives you a great delivery layer. It does not give you a personalization model. ×marble is the knowledge-graph engine that decides what cohort a user belongs to, what content to show them, and why — and exposes the result as an API your edge Worker can call (or as cohort assignments your origin pushes into KV). We separate the model from the delivery on purpose: the model needs to reason about the user across time and channels; the delivery needs to be a sub-20ms key lookup.

If you want personalized YouTube briefings, we built Vivo and Video on top of it. If you want to personalize music, Marble x Music does that for Spotify and Apple Music. If you want to personalize your own marketing site or product, ×marble is the engine. We won't ship you a Workers KV implementation, but we will give you the cohort assignments to put in KV.

FAQ

How fast is Cloudflare Workers KV for personalization?

After Cloudflare's 2025 rearchitecture, the new backend serves p99 KV reads in under 5 ms from the local PoP (InfoQ). The full Worker round-trip — including cookie parse, KV read, and response rewrite — typically lands under 20 ms p50 for a Cloudflare Workers KV personalization pipeline, well within the budget for personalizing above-the-fold content without delaying first byte.

What is Workers KV best for in a personalization stack?

Workers KV personalization is a read-heavy, write-rare workload — cohort definitions, A/B variant configs, feature flags, and small routing decisions. Anything that you read on every page view but only update a few times per day per key. For per-user write-heavy state (session events, recent reactions) use Durable Objects. For relational data and ad-hoc SQL use D1.

Is Cloudflare KV eventually consistent?

Yes. Writes to Workers KV propagate globally within roughly 60 seconds. Reads from a PoP that hasn't received the latest write will see the previous value. For cohort routing and A/B testing this is fine. For "user just clicked, show next" interactions you need Durable Objects or a direct API call to your origin.

What are the Cloudflare Workers KV free tier limits?

The free tier includes 100,000 KV reads per day, 1,000 writes per day, 1,000 deletes per day, and 1 GB of storage (Cloudflare pricing). Paid tier includes 10 million reads, 1 million writes, 1 million deletes, and 1 GB storage per month, then $0.50 per million reads, $5.00 per million writes, and $0.50 per GB-month.

How does Cloudflare cohort routing compare to client-side personalization?

Cloudflare cohort routing makes the variant decision before any HTML is sent, so the user never sees the original content flash. Client-side personalization fires after the page loads, which causes layout shift, hurts CWV, and is invisible to crawlers. For SEO-sensitive pages and first-time visitors, edge cohort routing is the only approach that wins on both performance and indexability.

Further reading

the product behind these notes

×marble is the personalization graph.

One API. A living knowledge graph per user. Day-zero ready, explainable by construction. We built it so you don't have to.

See ×marble