Cloudflare KV vs D1 vs Durable Objects

Pick Workers KV when reads vastly outnumber writes and a minute of stale data is harmless. Pick D1 when you need SQL over a whole dataset. Pick a Durable Object when one piece of state needs a single writer and strong consistency. Most Workers projects end up using at least two of the three.

The size of your data is rarely what settles it. What settles it is how many writers touch the same record, and how fast a write has to become visible somewhere else on the planet.

Cloudflare KV vs D1 vs Durable Objects at a glance

Workers KV is a key-value store that caches on Cloudflare’s network and is eventually consistent. Keys are capped at 512 bytes, values at 25 MiB, key metadata at 1024 bytes, and writes to the same key at one per second. Those numbers come from the KV limits page.

D1 is SQLite as a managed service. A single database maxes out at 10 GB on Workers Paid (500 MB on Free), you get 1,000 queries per Worker invocation on Paid and 50 on Free, and a query can run for 30 seconds before it is cut off. The D1 limits page also says the thing most people miss: “each individual D1 database is inherently single-threaded, and processes queries one at a time”.

A Durable Object is compute and storage fused into one globally addressable instance. Each object gets up to 10 GB of SQLite storage, a 2 MB row limit, 100 columns per table, and a soft limit of 1,000 requests per second. Free accounts get 100 Durable Object classes and 5 GB of storage; paid accounts get 500 classes and no per-account storage cap on the SQLite backend. The Durable Objects limits page has the full list.

None of those ceilings is usually the deciding factor. Consistency is.

When is Workers KV the right choice?

Feature flags, redirect maps, asset manifests, per-tenant configuration, anything a deploy step writes and every request reads. KV is very good at that and very fast once a key is hot in a location.

It is a bad fit the moment a user’s own write has to be readable straight afterwards. Cloudflare’s how KV works page is blunt about it: “Changes may take up to 60 seconds or more to be visible in other global network locations as their cached versions of the data time out.” Negative lookups are cached too, so a brand new key can read back as null for a minute in a different colo. The default cache TTL is 60 seconds and the minimum you can set is 30.

Then there is the one-write-per-second-per-key limit, which quietly rules out counters, locks, queues, and anything else where the same key is the contention point. If you find yourself writing retry logic around a KV write, you have picked the wrong store.

What is D1 good for, and where does it stop?

Relational data with a schema you actually want to query. Joins, indexes, migrations, GROUP BY. If you have used SQLite anywhere else, the mental model transfers directly, including the parts we covered in our comparison of node:sqlite and better-sqlite3.

Read replication is the feature worth knowing about, because it changes how you write queries. You enable it per database (Settings, then Enable Read Replication, or "read_replication": {"mode": "auto"} over the REST API) and then route queries through the Sessions API:

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // "first-unconstrained" lets the first query land on any replica.
    // Use "first-primary" when the very first read must see the newest write.
    const session = env.DB.withSession("first-unconstrained");

    const { results } = await session
      .prepare("SELECT id, title FROM posts WHERE published = 1 ORDER BY id DESC LIMIT 20")
      .all();

    return Response.json(results, {
      headers: { "x-d1-bookmark": session.getBookmark() ?? "" },
    });
  },
};

A session gives you sequential consistency: monotonic reads, and read-your-own-writes within the session. The bookmark from session.getBookmark() is how you carry that guarantee across requests, usually in a cookie or a header. Without a session you are back to reading whatever replica answers first. The read replication docs spell out the ordering guarantees.

Where D1 stops is writes. Replication scales reads, not writes, and the primary is still one SQLite database handling one query at a time. A workload with many concurrent writers hammering the same tables will queue behind itself no matter how many replicas you add.

When do you need a Durable Object?

When the correct answer depends on serialising access to one thing. A chat room, a document, a game lobby, a per-key rate limiter, a per-tenant job queue, a WebSocket hub. Durable Objects are, in Cloudflare’s words, “single-threaded and cooperatively multi-tasked, just like code running in a web browser”, so two requests to the same object cannot interleave in the middle of a read-modify-write.

Here is a rate limiter, which is the case that most often gets built wrongly on KV:

import { DurableObject } from "cloudflare:workers";

export class RateLimiter extends DurableObject {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.ctx.storage.sql.exec(
      "CREATE TABLE IF NOT EXISTS hits (ts INTEGER NOT NULL)",
    );
  }

  async take(limit: number, windowMs: number): Promise<boolean> {
    const now = Date.now();
    this.ctx.storage.sql.exec("DELETE FROM hits WHERE ts < ?", now - windowMs);

    const [{ n }] = this.ctx.storage.sql
      .exec<{ n: number }>("SELECT COUNT(*) AS n FROM hits")
      .toArray();

    if (n >= limit) return false;

    this.ctx.storage.sql.exec("INSERT INTO hits (ts) VALUES (?)", now);
    return true;
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const key = request.headers.get("cf-connecting-ip") ?? "anon";
    const stub = env.RATE_LIMITER.getByName(key);

    return (await stub.take(60, 60_000))
      ? new Response("ok")
      : new Response("slow down", { status: 429 });
  },
};

getByName(name) replaces the older idFromName() then get() pair and is the shorter path to a stub for a known key. The wiring in wrangler.jsonc:

{
  "name": "api",
  "main": "src/index.ts",
  "compatibility_date": "2026-08-01",
  "durable_objects": {
    "bindings": [{ "name": "RATE_LIMITER", "class_name": "RateLimiter" }]
  },
  "exports": {
    "RateLimiter": { "type": "durable-object", "storage": "sqlite" }
  }
}

That exports block is newer than most tutorials you will find. It replaces the imperative migrations array with its new_sqlite_classes tags, the two are mutually exclusive, and once a Worker has been deployed with exports you cannot go back to migrations. Existing projects can stay on the old array. New ones should not start there.

Also note that Cloudflare has closed the door on the key-value storage backend for Durable Objects: new namespaces with that backend are no longer created for accounts that do not already have one. SQLite is the backend now, and it is the one that supports point-in-time recovery over the last 30 days.

The ceiling to design around is that 1,000 requests per second per object. One object holding global state is a bottleneck with a hard number attached to it. Shard by appending a bucket suffix to the name when a single key gets that hot.

Where do R2 and Hyperdrive fit?

R2 is S3-compatible blob storage with strong consistency per object and no egress fees. Anything larger than KV’s 25 MiB value limit was always going to live there: uploads, generated PDFs, exports, backups.

Hyperdrive is the option people forget. If you already run Postgres or MySQL with a mature schema, Hyperdrive pools connections and caches queries in front of it, and you skip the migration entirely. We reach for that more often than for D1 on projects where the database already exists and works, because rewriting a decade of SQL to fit a 10 GB SQLite ceiling is a poor trade.

What I would actually do

On a new Workers project: D1 for the relational core, one Durable Object class per thing that needs a lock or a live connection, and KV only for data written at deploy time and read on every request. R2 for bytes. If you are still deciding where the Worker itself runs, our notes on Pages versus Workers cover that part.

The case where I would not start with D1 is a write-heavy workload with many concurrent writers on the same rows. A single-threaded database processing queries one at a time is the wrong shape for it, read replicas do not fix a write bottleneck, and you will be sharding across Durable Objects within six months anyway. Start there instead.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch