↑ top
← Back to blog
EN | ES

KV vs D1: When to Use Each on Cloudflare Workers

KV is a global key-value store with eventual consistency. D1 is SQLite with strong consistency but regional writes. Choosing wrong costs you later.

✦ AI Summary

Cloudflare Workers gives you two storage primitives that look similar but behave very differently. Both are accessible as Worker bindings. Both store persistent data. The choice between them is not obvious from the docs.

KV vs D1: When to Use Each on Cloudflare Workers

Cloudflare Workers gives you two storage primitives that look similar but behave very differently: KV (key-value store) and D1 (SQLite database). Both are accessible as Worker bindings. Both store persistent data. The choice between them is not obvious from the docs.

Here's the mental model that makes the decision straightforward.

The Core Difference

KV is a globally distributed cache. Writes propagate to all edge locations within 60 seconds. Reads are fast everywhere, but you can read a stale value immediately after a write. It's eventually consistent.

D1 is a SQLite database with a single write region. Reads are served from regional replicas. Writes are strongly consistent — a read after a write always sees the written value — but writes have higher latency because they go to the primary region.

This single distinction drives most of the "when to use which" decision.

Use KV For

Content that's written infrequently and read often. Blog post content, configuration, cached AI results. The post body for a blog is written once (on publish) and read thousands of times. KV's global read performance is ideal. Eventual consistency doesn't matter because the content rarely changes.

javascript// Writing once at publish time
await env.POSTS_KV.put(`post:${slug}`, markdownContent);

// Reading on every page view — fast globally
const content = await env.POSTS_KV.get(`post:${slug}`, "text");

Cache entries with TTLs. KV supports per-key expiration. Rate limiting, session tokens, temporary flags — anything with a natural expiry.

javascript// Rate limit key that expires after 60 seconds
await env.POSTS_KV.put(`ratelimit:${ip}:${slug}`, "1", { expirationTtl: 60 });

Large values. KV supports values up to 25MB. D1 rows are not designed for large blobs.

Use D1 For

Data that needs to reflect writes immediately. View counters, user preferences, anything where a user takes an action and expects to see the result right away.

A view counter in KV would work, but a visitor who increments the counter might read the pre-increment value on the next request (eventually consistent). In D1, the read after a write always returns the updated value.

javascript// D1: upsert + read in the same request — always returns updated count
await env.DB.prepare(
  "INSERT INTO post_views (slug, views) VALUES (?, 1) ON CONFLICT(slug) DO UPDATE SET views = views + 1"
).bind(slug).run();
const r = await env.DB.prepare("SELECT views FROM post_views WHERE slug = ?").bind(slug).first();

Queryable data. Need to sort posts by view count? Filter by date range? Join across tables? SQL handles this cleanly. KV requires fetching all values and filtering in JavaScript.

sqlSELECT slug, views FROM post_views ORDER BY views DESC LIMIT 10

Relational data. If you need to enforce relationships between entities — tags belonging to posts, comments belonging to users — SQL constraints and joins are the right tool.

The Overlap Zone

Both can store simple key-value pairs. The question is which tradeoffs you want.

KV D1
Read latency ~5ms globally ~10-20ms (replica)
Write consistency Eventual (~60s) Strong
Write latency ~10ms ~20-50ms
Max value size 25MB ~1MB per row
Query language None (get/put/list) Full SQL
TTL support Yes Manual (delete + cron)

For this blog: post content and AI summaries live in KV (written once, read often, large values). View counters live in D1 (need strong consistency, need to sort by count).

The Mistake to Avoid

Using KV for data that needs to reflect mutations immediately. The classic failure mode: user clicks a reaction button, you write to KV, then you read to show the updated count — and you get the old count because KV hasn't propagated yet.

The fix is either D1 (strongly consistent) or optimistic UI (show the expected value in the client without reading back from the server).

KV's eventual consistency is not a bug — it's the tradeoff that makes global low-latency reads possible. Just don't use it when you need immediate read-after-write consistency.

Was this helpful?