Storage

KV

KV is the storage block for read-heavy data. A globally distributed key-value store with reads cached at the edge — sub-millisecond from every Worker. Writes propagate worldwide in about a minute, and that tradeoff is the whole point: feature flags, per-user preferences, edge config, lookup tables. The theme you're seeing this page in came from KV.

// two demos + one pattern on this page: theme preference, pinned message, then the keyspace ↓

KV theme-preference request flow A browser sends a request to a Cloudflare Worker. The Worker reads the user's session cookie, looks up their saved theme in KV (which is cached at the edge), and returns the page rendered in the right theme. browser your visitor worker routes + reads cookie kv theme:<sessionId> request response get / put value session cookie edge-cached
// theme preference round-trip

Why KV feels different

  • Reads at the edge. Every Cloudflare colo caches recently-read keys. A read from a Worker is typically single-digit milliseconds, often sub-millisecond on cache hit. Your data is colocated with your code.
  • Eventually consistent. Writes propagate worldwide in about 60 seconds. That's the price of the read speed — and the reason KV is wrong for atomic counters, race-sensitive logic, or anything where two clients writing at the same time has to be resolved precisely.
  • Sized for hot data, not bulk. Values up to 25 MiB, keys up to 512 bytes. Plenty for config, preferences, JSON blobs. Not for files (that's R2) or relational data (that's D1).
  • A binding, like everything else. Declare it in wrangler.jsonc, your code reads env.KV.get("key"). No connection setup, no credentials in code, no SDK to install.

Try it

Theme preference

Click a button below. Your choice is stored in KV under a key tied to a session cookie, and read back on every page load — so it survives reloads.

Theme:

How the theme handler works

On GET, the Worker reads theme:<sessionId> from KV. On POST, it writes the new value with a 90-day TTL so abandoned sessions self-expire. The session cookie is created lazily on the first visit.

src/handlers/kv.ts
// /api/kv/theme — read or write a per-user preference.
// The session cookie is minted on the first GET.

if (request.method === "GET") {
  const theme = await env.KV.get(`theme:${session.id}`);
  return Response.json({ theme });
}

if (request.method === "POST") {
  const { theme } = await request.json();
  await env.KV.put(`theme:${session.id}`, theme, {
    expirationTtl: 60 * 60 * 24 * 90, // 90 days — abandoned sessions self-expire
  });
  return Response.json({ ok: true });
}

Try it

Pinned message

Pinned messages, maintenance banners, feature flags — same pattern, different name. Set the key once via the wrangler CLI; every visitor's request reads it from KV's edge cache. The yellow note below is exactly that pattern, with a single key called pinned_message.

How the pinned message works

The Worker reads the key server-side during the page render, so the message appears on first paint — no flash, no client-side fetch. One line of KV does the work.

src/index.ts
// src/index.ts — fetched server-side on every page request.
// The value lands in renderKvPage as a prop, so first paint
// already has the pinned message.

const pinnedMessage = await env.KV.get("pinned_message");

The keyspace as an index

The two demos above each use a single key. KV's list method reveals a different pattern: the keyspace itself becomes an index.

Pick a prefix and write keys under it. For a tenant's feature flags, that might look like:

  • flag:tenantId:new-dashboard
  • flag:tenantId:bulk-export
  • flag:tenantId:beta-search

Now list({ prefix: "flag:tenantId:" }) returns all three keys in one call. Crucially, list returns each key's metadata without fetching the value — so the admin panel can render a flag inventory (name, enabled state, who set it, when) without a single get. This is how feature-flag dashboards, active-session audits, and per-tenant config inventories are built on KV.

// list pattern
// List all feature flags for a tenant — no value fetches needed.
// Metadata carries enough to render the admin panel.

const { keys } = await env.KV.list({
  prefix: `flag:${tenantId}:`,
});

const flags = keys.map((key) => ({
  name: key.name.replace(`flag:${tenantId}:`, ""),
  enabled: key.metadata?.enabled ?? true,
  createdAt: key.metadata?.createdAt,
}));

When KV is the right tool

  • Feature flags — read on every request, write occasionally from a dashboard
  • Per-user preferences — theme, language, locale, dashboard layout
  • Edge config — site name, contact email, maintenance banner, API endpoints
  • Lookup tables — country code → currency, ASN → org, slug → article ID
  • A/B test assignments — bucket each user once, read on every page
  • Cached upstream responses — store an API call's result keyed by URL

For atomic counters or strongly-consistent state, reach for Durable Objects. For relational queries or anything you'd reach for SQL for, reach for D1. For files larger than 25 MiB, reach for R2. For ephemeral per-request caching scoped to a single Cloudflare colo, the Cache API is sometimes a better fit — and costs nothing.