KV, D1, R2 or Durable Objects: a decision tree that holds up

Four storage primitives with overlapping descriptions and very different consistency models. Pick by access pattern, not by which one sounds most like a database.

/ 5 min read

Every Cloudflare storage product is described as “fast, global and cheap”, which is accurate and completely useless for choosing between them. The distinction that matters is the consistency model and what an individual read or write costs you in latency.

Here is the shape of the decision:

flowchart TD
  A[What are you storing?] --> B{Large binary blobs?}
  B -->|Yes| R2[R2<br/>objects, no egress fees]
  B -->|No| C{Do concurrent writers<br/>need to agree?}
  C -->|Yes| DO[Durable Objects<br/>one writer, strongly consistent]
  C -->|No| D{Do you need queries,<br/>joins, transactions?}
  D -->|Yes| D1[D1<br/>SQLite, relational]
  D -->|No| E{Read-heavy, tolerant<br/>of stale reads?}
  E -->|Yes| KV[Workers KV<br/>eventually consistent cache]
  E -->|No| DO

The rest of this post is why each branch goes where it does.

Workers KV - a cache that looks like a database

KV is a globally replicated key-value store optimised for reads massively outnumbering writes. A hot key is served from the local data centre in single-digit milliseconds. A cold key costs a trip to a central store.

The critical property: writes are eventually consistent. After a write, other locations may serve the previous value for up to around a minute, and a read-after-write from the same Worker is not guaranteed to see it either. There is also a write throughput limit per key that makes rapid updates to one key a bad idea.

Good fits: feature flags, routing tables, redirect maps, edge configuration, cached API responses, signed-URL allowlists.

Bad fits: anything counted, anything ordered, anything a user expects to see immediately after they changed it. The classic failure is a view counter - concurrent increments read the same old value and overwrite each other.

R2 - objects, and the egress bill

S3-compatible object storage, priced with no egress fees, which is the whole reason it exists. If you are storing user uploads, build artefacts, backups, video, or anything you serve a lot of bytes from, this is the answer and there is not much to deliberate.

Two practical notes. Public access should almost always go through a Worker or a custom domain with rules attached, not a bucket left open - an R2 bucket with public access enabled is a bucket anybody can enumerate cost against. And R2 is object storage: listing is not free, and it is not a filesystem, so avoid designing anything that needs to LIST a prefix of a million keys on the request path.

D1 - SQLite, with the sharp edges of SQLite

D1 gives you real SQL: schemas, indexes, joins, transactions, prepared statements. For anything relational under a few gigabytes it is the least surprising option.

const { results } = await env.DB.prepare(
  'SELECT id, title FROM posts WHERE published = ?1 ORDER BY pub_date DESC LIMIT 10'
)
  .bind(1)
  .all();

Always use .bind(). String-concatenated SQL is an injection hole here exactly as it is everywhere else, and a Worker being “at the edge” does nothing to help.

What to watch: a database has a primary location, so a Worker in Sydney writing to a database in Europe pays the round trip. Read replication and the Sessions API exist to soften that for read-heavy workloads - enable them deliberately and check the current docs, because this area moves quickly. Also: one query per network hop. Chatty ORMs that issue a query per row will feel every millisecond, so fetch what you need in one statement.

Durable Objects - the one that gives you coordination

Everything above scales by giving up coordination. Durable Objects give it back.

A Durable Object is a named instance with its own storage, and Cloudflare guarantees exactly one active instance per name globally, executing one event at a time. That single-threadedness is the feature: inside an object you can read, decide and write without a race, because there is no second writer.

export class Counter extends DurableObject {
  async increment() {
    // No transaction needed. Nothing else runs in here concurrently.
    const value = (await this.ctx.storage.get('count')) ?? 0;
    await this.ctx.storage.put('count', value + 1);
    return value + 1;
  }
}

That is the code that is broken in KV and correct here.

Use them for: chat rooms, collaborative documents, game sessions, seat or stock reservations, rate limiters that must be exact, per-tenant queues, anything holding a WebSocket. They also get alarms for scheduled work and, with SQLite-backed storage, real SQL inside the object.

The cost is that you must partition your problem into objects. One object per document, per room, per user, per tenant - fine. One object for the whole application - you have built a global lock, and every request in the world now queues behind it.

The two that are not on the diagram

  • Cache API / Cache Reserve - if the answer is “the response to this exact request”, cache the response instead of storing the data. It is cheaper than every option above.
  • Hyperdrive - if the data already lives in Postgres or MySQL somewhere, do not migrate it. Hyperdrive gives Workers pooled, cached access to the existing database, which is usually the correct move for an established system.

Combining them

Real applications use several. A typical shape:

  • R2 for the uploaded files
  • D1 for the metadata and anything you query
  • KV for configuration read on every request
  • Durable Objects for the live session and the counters that must be right

None of that is over-engineering. It is picking the primitive whose consistency model matches what each piece of data actually needs - which is the only reliable way to choose.