Engineering

D1, KV, Durable Objects or R2? How we choose Cloudflare storage

How we pick between Cloudflare D1, KV, Durable Objects and R2, from real uses in our apps: chat rooms, cloud saves, leaderboards, OTA updates and uptime alerts.

Cloudflare gives a Worker four places to keep data, and the first time you build something real on it, choosing between them is genuinely confusing. They overlap. D1 is SQLite. Durable Objects can also be SQLite. KV and R2 both store blobs by key. The pricing pages are clear about what each costs and say very little about what each is for.

We've built enough on Cloudflare now that we've got opinions. Our apps use all four, often in the same Worker: Perron, our train-station chat app, binds thirteen Durable Object classes, a D1 database, a KV namespace and two R2 buckets. Our games keep cloud saves in R2 and indexes in D1. Our booking platform Lorenly uses KV for an uptime monitor and R2 for cached pages.

This post is the way we actually decide, with the real uses behind each choice, and a few places where we picked wrong first.

The short version

If we had to put it in one paragraph: a Durable Object is for a thing that has an identity and needs to be consistent, like a chat room, a rate-limit counter or a player's entitlements. D1 is for questions across many things, like "which account owns this subscription?". R2 is for files and for anything big, immutable or written once and read many times. KV is for small values that are read far more often than they're written, and where being a minute out of date is fine.

The rest of the post is why.

Durable Objects: one thing, one place, in order

A Durable Object is a single-threaded actor with its own private storage. There's exactly one instance of a given object in the world at any moment, and it handles one request at a time. That makes it the only one of the four where "read a value, decide, write a value" is safe without thinking about races.

Perron's chat rooms are Durable Objects for exactly that reason. Messages arrive in order, and everyone in the room sees the same order, because one object handles them all. The same goes for things that have to be counted exactly: our rate limiter for writes, which must not let thirty-one posts through a limit of thirty, and the guard that burns each device proof so it can't be replayed. That guard's comment says it neatly: a Durable Object is single-threaded, so insert-and-report is atomic by construction.

Entitlements are a Durable Object per device key too. Which cosmetics has this device paid for? That's a question with one right answer that must never be stale. If you paid, you keep it.

The cost model rewards objects with natural boundaries. Each object's storage is its own SQLite database, so a query inside a room never touches another room's rows. And objects that sit idle with hibernating WebSockets aren't billed for duration.

Where Durable Objects are weak is anything across objects. You can't ask "show me every room with more than ten people" without asking every room. And a single global object, the tempting "just put it all in one place" design, becomes a bottleneck and a cost centre. Our global Stats object once read 4.55 million SQLite rows in a day because of one careless query, which we wrote up in cutting Durable Object costs.

D1: the relational index across everything else

Which brings us to D1. Once you have a pile of per-entity Durable Objects, some questions get awkward fast. Which account owns this Apple subscription? Which devices belong to this account? How many journeys were logged yesterday? Each is a simple SQL query against a table of all of them, and impossible against a thousand separate objects.

That's how Perron uses D1. The code describes it as the one relational store across the otherwise per-entity Durable Objects, and it's kept deliberately thin. Accounts, devices and subscriptions. A log of emails sent. Deletion requests. Journeys for the delay-refund feature. Client versions and OTA updates seen. Each of those is a cross-cutting question, and none of them is the source of truth for something a user is waiting on in real time. Entitlements stay in their Durable Objects, and D1 indexes them.

It's also best-effort, on purpose. A missing binding or a D1 blip must never break posting a message. If D1 is down, a few admin views are stale. Chat carries on.

Our game backends use D1 the same way, as an index next to R2. Ten Million Cells keeps player saves in R2, and a tiny D1 table records which install saved on which day and how big the save was. The comment in its config says why: a bucket has no index, and listing it to answer "is there a save?" would scan the whole thing. Transfer codes live in D1 too, because a code has to stop working on its own after fifteen minutes, and an expiry column is a better tool for that than any object store.

Scrapheap's update server uses D1 as a control plane for releases. R2 holds the content and wins any disagreement. D1 holds the release list, channel state, an audit log and daily counts, because every screen a dashboard wants is a GROUP BY. Against R2, each of those is a prefix listing followed by N object fetches. Against D1, each is one query.

The D1 gotchas we hit

D1 is SQLite, and it mostly behaves like SQLite, but a few things caught us.

Schema bootstrap on a cold start is expensive. Perron originally ran its schema, a few dozen CREATE TABLE IF NOT EXISTS statements, every time a cold isolate first touched the database. On the free plan, with 50 subrequests per invocation, every cold isolate replayed 48 statements before it could answer anything. Now a schema_meta table records that the bootstrap ran, and a cold isolate checks one row instead.

d1.exec() splits its input on newlines. A migration string with a newline in the middle of a statement will be split into two broken ones. Our schema is written readably and collapsed onto single lines by a helper before it's executed.

SQLite can't add a UNIQUE column with ALTER TABLE. Add the column, then add a unique index.

There's no rename. When we wanted to rename the Ten Million Cells database, the API rejected a name field outright. We created a new database, copied the two live tables across, verified them row for row, and switched the binding.

Each D1 database is single-threaded, which is easy to forget because it looks like a regular database. It's fast, but it's one writer. For per-entity hot paths that's another reason to keep them in Durable Objects.

And bind the name your code expects. Scrapheap's config once had wrangler's suggested binding name, not the DB the code reads, which bound a database the code never looked at and left the admin answering 503 with everything apparently configured.

R2: files, and anything that's written once

R2 is object storage with no egress fees, and that second part changes what you'd use it for. Anything big, anything downloaded a lot, anything that's written once and read many times belongs here.

Our self-hosted Expo update servers keep every release in R2: bundles, assets and signed manifests. Releases are never modified; rolling out or back only rewrites a small pointer file. We wrote about that in self-hosted Expo updates.

Our cloud-save service keeps every save as an immutable blob, with a small pointer object updated by compare-and-swap using R2's conditional writes. Two devices saving at once can't overwrite each other: the loser gets a 412, and its bytes stay on disk under their own version. That design has its own post, and one hard-won detail worth repeating: etagMatches wants the raw etag, not the quoted httpEtag. Passing the quoted one makes the conditional write throw. Our mock couldn't catch that. A live wrangler tail did.

Our telemetry ingest writes each batch as an R2 object named by the SHA-256 of the client's raw bytes. Content addressing makes retries free: the same batch always lands on the same key. The first version hashed the body after we'd added a received-at timestamp, which gave every retry a new key. A contract test caught it on the first run. The R2 write is the acceptance. If it fails, the client gets a 503 and retries.

Separate buckets per kind of data are worth the small hassle. Our Mahjong game keeps content, saves and feedback in three buckets, so a bug or a leaked key affects one kind of data, not all of it. One kind of data, one blast radius.

R2 does have limits as a database. You can list by prefix, and that's your only query. Lots of tiny objects make publishing slow. Perron's German timetable data started as 5,500 per-station files, which took about 23 minutes to upload at the API's rate, and the per-trip data would have been 51,863 files and an eight-hour publish. Packing stations into 256 shards cut the upload to about a minute, trips went into about a hundred files, and the biggest shard still parses in a few milliseconds.

KV: small, read-mostly, eventually consistent

KV is the one people reach for first and should reach for least. It's a global key-value store optimised for reads. Writes take time to show up everywhere, and you can only write the same key about once a second. On the free plan you get 100,000 reads and only 1,000 writes a day.

Used for what it's good at, it's excellent. Perron caches its daily leaderboards in KV with a 24-hour TTL. Without it, every cold isolate recomputed the boards from the single global Stats object, so at any real scale that object took a computation storm once a day. With KV, the board is computed once a day, globally, and read from everywhere. There's an in-memory cache in front of KV in each isolate too, so a warm isolate doesn't even pay for the KV read. Boards change slowly, so eventual consistency is fine.

Lorenly's uptime monitor is a lovely small example. A Worker on a five-minute cron checks one public health endpoint, which reports unhealthy if the database has been unreachable past a grace window, so one URL covers the host, the proxy, the app and the database. It pages after two failures in a row and re-alerts after an hour. A Worker remembers nothing between runs, so the failure counter lives in KV. And here's the nice part: healthy is represented by no KV record at all. KV is written only while an outage is being tracked and cleared on recovery, so routine checks spend none of the daily write allowance.

Where we picked wrong

Lorenly's site template first cached rendered HTML in KV. Hourly revalidation across many Cloudflare locations ran through the free plan's 1,000 daily writes, and once the writes were gone, a cold location couldn't store a page it had rendered. On the free CPU budget, that meant some pages couldn't be served at all. The HTML moved to R2, which has no small daily write ceiling.

KV still has a job there, a much smaller one: a tiny per-site purge marker. When a salon edits its page, one KV write bumps a generation number. KV's edge read cache is 30 seconds at minimum, plus a two-second in-memory memo, so the next request for that site anywhere renders fresh within about 32 seconds. That's the kind of thing KV is for: small, rare writes, read everywhere.

We've also learned not to use a Durable Object as a cache. Perron's FeedCache holds the national train feed in memory and deliberately persists nothing, because writing 397 stations of departures to SQLite on every refresh would have cost six figures of rows written a day.

How we'd decide, as questions

Does this thing have an identity, and does it need to be exactly right? A Durable Object, keyed by that identity.

Do I need to ask questions across many of those things? A D1 table as an index, kept thin, with the Durable Objects or R2 as the source of truth.

Is it a file, or big, or written once and read a lot? R2, ideally immutable, ideally content-addressed.

Is it small, read far more than written, and fine to be a minute stale? KV, and check the write allowance before relying on it.

Do I just need a value to stop recomputing something for a while? Often the Cache API, which isn't metered at all, or a variable in the isolate.

Most real features end up using two of these together. That's normal. The mistake is using one of them to do another one's job.

For the cost side of these choices, see Workers free plan limits and cutting Durable Object costs.

Start a project

Have something you want built?

Tell us about it. We reply to every message with an honest take on whether we are the right fit.

Start a project