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.
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 readsenv.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.
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.
// /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 — 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-dashboardflag:tenantId:bulk-exportflag: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 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.