Tutorials Cloudflare Feature Focus

KV: The Edge Key-Value Store

CloudflareCloudflare Feature Focus20 minMay 19, 2026Beginner

KV is the storage primitive most often used wrong. People reach for it because the API is one line — await env.KV.get('key') — and then ship something that quietly breaks because they didn't read the small print on consistency. This chapter is the small print, written with concrete code and concrete failure modes.

Workers KV is a globally-replicated, read-optimised key-value store. The model is write to a central origin → fan-out to every edge PoP. Reads are blisteringly fast (sub-10ms in cache, ~50ms on a miss). Writes are slow to converge — up to 60 seconds before every PoP sees the new value. That trade-off, and only that trade-off, is what KV is.

When KV is the right answer

Use KV when the value:

Concrete examples that fit:

When KV is exactly the wrong answer

The "rate limit by IP in KV" anti-pattern is the canonical mistake. The bug appears under load, the metric looks plausible, and the actual count is wrong by 20–50%. Don't do it.

The binding

[[kv_namespaces]]
binding = "CACHE"
id = "..."

Just like R2 and D1, the runtime hands you env.CACHE with get, put, delete, and list. No SDK to import, no credentials to manage.

Read operations

// Default — string
const raw = await env.CACHE.get('hero-copy');
 
// JSON — KV does the parse for you
const config = await env.CACHE.get('site-config', 'json');
 
// ArrayBuffer / Stream variants for binary
const bytes = await env.CACHE.get('blob-key', 'arrayBuffer');
const stream = await env.CACHE.get('blob-key', 'stream');
 
// Get with metadata
const { value, metadata } = await env.CACHE.getWithMetadata('hero-copy');

Three properties of reads that matter:

  1. First read in a PoP is a cache miss — slower (~50ms+). Subsequent reads in the same PoP are fast (~5–10ms).
  2. Reads are always served from the PoP-local replica — strongly consistent with that replica, eventually consistent with the origin.
  3. A null return is indistinguishable from a deleted key. If you need "deleted" semantics, encode them in the value ({ status: 'tombstoned' }).

Write operations

// Plain string
await env.CACHE.put('hero-copy', 'Ship apps in a weekend.');
 
// With TTL — value disappears after N seconds
await env.CACHE.put('rate-attempt:' + ip, '1', { expirationTtl: 60 });
 
// With absolute expiration (unix seconds)
await env.CACHE.put('promo-banner', 'Memorial Day sale', { expiration: 1748908800 });
 
// With metadata (returned alongside the value on getWithMetadata)
await env.CACHE.put('user:' + uid, JSON.stringify(profile), {
  metadata: { tier: 'pro', updated: Date.now() },
  expirationTtl: 3600,
});

Four properties of writes:

  1. put returns as soon as the write hits the origin. It does not wait for global fan-out. Subsequent reads in remote PoPs may return the old value for up to 60 seconds.
  2. expirationTtl is minimum 60 seconds. Don't try to use KV for sub-minute caches.
  3. No atomic increment. get → +1 → put between two Workers races; the loser silently overwrites.
  4. Write rate-limit: ~1 write/key/sec. Don't hammer the same key.

The TTL trick — and the 60-second floor

expirationTtl is the most-used KV option for a reason: it gives you a free garbage collector. The minimum TTL is 60 seconds, so the smallest sensible KV cache is "this entry lives for at least a minute." If you want a 5-second cache, KV is not the right tool — you want the Cache API or an in-memory Map inside a Durable Object.

A practical pattern: read-through cache in front of an expensive D1 query.

async function getProductSummary(env, slug) {
  const cached = await env.CACHE.get('product:' + slug, 'json');
  if (cached) return cached;
 
  const row = await env.DB.prepare(
    'SELECT id, name, price, hero_url FROM products WHERE slug = ?'
  ).bind(slug).first();
  if (!row) return null;
 
  await env.CACHE.put('product:' + slug, JSON.stringify(row), {
    expirationTtl: 300, // 5 minutes
  });
  return row;
}

This is the "right" use of KV: the source of truth is D1, KV is a read-through edge cache, and a 5-minute stale window is acceptable. The expensive D1 query happens once per PoP per 5 minutes; everyone else gets a sub-10ms hit.

When you UPDATE the product, also call env.CACHE.delete('product:' + slug). The delete is eventually consistent too — within 60 seconds globally — but it tightens the staleness window from 5 minutes to ~30 seconds, which is usually fine.

list and pagination

const listed = await env.CACHE.list({ prefix: 'session:', limit: 1000 });
for (const k of listed.keys) {
  console.log(k.name, k.metadata, k.expiration);
}
if (!listed.list_complete) {
  const next = await env.CACHE.list({
    prefix: 'session:',
    limit: 1000,
    cursor: listed.cursor,
  });
}

Two things to remember about list:

The consistency model, in plain English

You write K=v1 at the origin. The fan-out to every PoP starts. Until that fan-out completes (anywhere from a few seconds to 60 seconds, depending on PoP and load), readers may see:

There is no "synchronous" mode. You can't pay for stronger consistency. KV is the wrong tool if "now means now" anywhere in your design.

Free vs paid

The KV free tier is 100,000 reads/day, 1,000 writes/day, 1,000 deletes/day, 1,000 list/day, 1 GB storage — generous for low-write read-cache use cases, painful if you mistake it for a real database. Paid pricing on the Workers Paid plan is $0.50 per million reads, $5 per million writes/deletes/lists, $0.50 per GB-month.

The asymmetry is the whole story: reads are 10× cheaper than writes. Design around that, or use D1.

A clean architecture: KV in front, D1 behind

The most reliable production pattern is to use KV as a read-through cache layer on top of D1, never as a primary store. The shape:

Loading diagram…

The contract:

The same diagram with the wrong arrow — counters living in KV, written from many regions, read from D1 — is the bug-fest version most people accidentally ship.

What if I want sub-minute consistency at the edge?

You have three real choices and none of them is KV.

The pros and cons cheat sheet

Pros

Cons

When to reach for KV

Use KV when all of the following are true:

If any of those is false, KV is the wrong primitive. The next chapter walks through Durable Objects — the tool you reach for when KV's consistency model isn't enough, when you need real transactions across calls, or when you want a single tiny stateful actor coordinating a chat room, a rate limit, or a Stripe webhook fan-out.

Ch 3: D1 — SQLite at the EdgeCh 5: Durable Objects — Strong Consistency at the Edge
Course PlatformBuild a Course Platform on CloudflareBuild a paid video course platform with Cloudflare Workers, R2, D1, auth, Stripe, and paywalls.Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.WebUltimate Web Development SeriesWeb development tutorials for HTML, CSS, JavaScript, Next.js, Workers, databases, and production shipping.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access