Choosing a Cloudflare Workers Storage Backend: KV, Durable Objects, D1, or R2
A decision guide for picking between Workers KV, Durable Objects, D1, and R2: consistency and write-pattern trade-offs, a comparison table, and a Worker that uses three backends in the roles they fit.
20 Jun 2026, 22:08 UTC

Every Cloudflare Worker that keeps state has to answer the same question: which of the four storage backends should hold this data? Picking wrong is expensive. Teams routinely put counters in Workers KV and lose increments to last-write-wins, or stuff per-user JSON blobs into D1 and pay for rows read when a key lookup would do. The useful takeaway: none of the four is a default. Each exists for a specific consistency and access pattern, and a single Worker often uses two or three of them at once.
The decision and its constraints
The decision is: for each piece of state in your Worker, which backend is the source of truth? The constraints that actually drive the answer are:
- Consistency. Can readers tolerate stale data for up to a minute, or must a read always see the latest write?
- Write pattern. Is the data written rarely and read constantly, or updated on nearly every request?
- Data shape. Is it a key-value pair, a relational model with joins, a per-entity aggregate (a counter, a session, a room), or a large binary object?
- Cost model. Each backend bills differently, so the access pattern changes the bill, not just the architecture.
Comparing the four options
| Backend | Consistency | Best fit | Anti-pattern | Cost model (verify current pricing) |
|---|---|---|---|---|
| Workers KV | Eventual; last-write-wins per key; propagation can take up to ~60s globally | Feature flags, config, redirects, read-heavy lookups | Counters, hot keys, anything authoritative | Per read/write/list operation |
| Durable Objects | Strong within one object; single-threaded execution plus transactional storage | Per-entity state: rate limiters, presence, game rooms, WebSocket coordination | Global aggregates requiring cross-object fan-out without explicit design | Per request plus duration (hibernation reduces idle WebSocket cost) |
| D1 | Strongly consistent writes at the primary; read replication available for reads | Relational data needing SQL, indexes, joins | Simple key lookups you could serve from KV; huge blobs | Per rows read/written plus storage |
| R2 | Strong for object operations | Files, media, backups, large payloads; S3-compatible, no egress fees | Low-latency per-request mutable state | Storage plus operation classes |
Plan requirements and limits (value sizes, storage caps, free-plan availability for Durable Objects) have changed over time. Confirm the current numbers in Cloudflare's docs before committing.
The trade-offs that matter in practice
KV's eventual consistency is a feature, not a bug — until it isn't
KV replicates reads to the edge closest to the user, which is why it's fast and cheap for read-heavy data. The price is that a write is not immediately visible everywhere, and concurrent writers to the same key silently overwrite each other. If a lost update would corrupt state — a balance, a counter, a lock — KV is disqualified as the source of truth no matter how convenient it looks. You can still use it as a cache in front of the real source.
Durable Objects serialize, and that's the point
Each Durable Object instance processes one event at a time against its own transactional storage, so increments, compare-and-swap, and rate-limit windows are exact without locks. The trade-off is that one object is one throughput unit, and aggregating across objects ("total across all users") requires your own fan-out design. WebSocket hibernation lets idle connections sleep without billing duration, which changes the economics of presence and chat workloads.
D1 buys you the relational model, not edge-local writes
Choose D1 when the data genuinely has relations — orders to customers, posts to authors — and you want SQL, migrations, and indexes. Writes go to a primary, so write latency depends on distance to that primary; read replication can bring reads closer to users, but its defaults have evolved, so check current behavior before designing around it.
R2 is cold storage with an S3 accent
R2 stores objects, not state. It pairs naturally with another backend: the blob lives in R2, the metadata and hot pointers live in KV, D1, or a Durable Object. The absence of egress fees is the headline, but the engineering point is that you should never be mutating R2 objects on a per-request hot path.
Concrete implementation: one Worker, three backends in their proper roles
This example Worker serves a download endpoint. It reads a feature flag from KV (staleness acceptable), increments an exact download counter in a Durable Object (strong consistency required), and streams the file from R2. Bindings in wrangler.toml:
[[kv_namespaces]]
binding = "FLAGS"
id = "<your-kv-namespace-id>"
[[durable_objects.bindings]]
name = "COUNTER"
class_name = "DownloadCounter"
[[r2_buckets]]
binding = "FILES"
bucket_name = "downloads"
[[migrations]]
tag = "v1"
new_classes = ["DownloadCounter"]
Worker code (run with a current wrangler version; module syntax):
export class DownloadCounter {
constructor(state) { this.state = state; }
async fetch() {
// Single-threaded per object: read-modify-write is exact.
const n = (await this.state.storage.get("count")) ?? 0;
await this.state.storage.put("count", n + 1);
return new Response(String(n + 1));
}
}
export default {
async fetch(request, env) {
// KV: bounded staleness via cacheTtl (seconds).
const enabled = await env.FLAGS.get("downloads-enabled", { cacheTtl: 60 });
if (enabled !== "true") return new Response("disabled", { status: 403 });
// Durable Object: one object per file for an exact counter.
const id = env.COUNTER.idFromName("file:report.pdf");
const count = await env.COUNTER.get(id).fetch(request);
// R2: stream the object; don't buffer it in the Worker.
const obj = await env.FILES.get("report.pdf");
if (!obj) return new Response("not found", { status: 404 });
const headers = new Headers();
headers.set("x-download-count", await count.text());
return new Response(obj.body, { headers });
},
};
Deploy from your machine with npx wrangler deploy (requires a Cloudflare account and API token; creating KV namespaces, DO migrations, and R2 buckets changes account state and may incur billing). The risk to note: the DO migration in wrangler.toml is what creates the class — deploying without it fails at runtime, not build time.
Validating the choice
- Confirm DO exactness. After deploying, fire N concurrent requests (for example
seq 1 200 | xargs -P20 -I{} curl -s https://<your-worker>/...from any shell) and check the finalx-download-countequals N. If you had used KV for this counter, you would expect a lower, nondeterministic number — that difference is the whole argument. - Demonstrate KV propagation. Update
downloads-enabledvianpx wrangler kv key put, then read it through the Worker from a different region (a VM or a VPN exit) and time how long the old value persists. Expect delay up to roughly a minute; measure rather than trusting the bound. - Watch the bindings fire. Run
npx wrangler tailagainst the deployed Worker and check the Workers dashboard metrics (invocations, KV operations, DO requests, R2 operations) to confirm each backend is exercised as designed and to sanity-check the cost model against real traffic.
Limitations
Latency and throughput vary by region, plan, and workload, so this guide deliberately gives a measurement method instead of absolute numbers. D1 read replication defaults and Durable Objects free-plan availability have both changed historically — verify current plan requirements and limits in the official docs before publishing architecture decisions. And if your data doesn't fit cleanly into one row of the comparison table, that usually means it belongs split across two backends, not forced into one.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.