Choosing KV, D1, R2, Durable Objects or Hyperdrive
A decision guide for Cloudflare's five storage products, so you can tell whether the store an agent picked matches the consistency, size and cost shape of your data β and spot the classic mismatches.
12 cards Β· 6 quiz questions Β· 10 min read
Across your Cloudflare account you have nine KV namespaces (sessions, OAuth state, rate limits, news, audio, progress), eight D1 databases and three R2 buckets (audio, documents, exports). Agents chose most of those homes for you, and some of the choices are almost certainly wrong β a rate-limit namespace in KV is a particularly common mistake. This article gives you the one question that separates the five products, then walks through your actual use cases.
The one question: what is your consistency and write shape?
Cloudflareβs own comparison page frames these products by data type and use case. In practice, the choice is nearly always decided by two things: how stale can a read be, and how often does the same thing get written.
| Product | Data | Consistency | Write shape it tolerates |
|---|---|---|---|
| Workers KV | Key-value | Eventually consistent, up to ~60 s propagation | Rare writes, ~1 per second per key |
| D1 | SQLite relational | Strong on the primary; replicas lag unless you use Sessions | Moderate; single-threaded per database |
| R2 | Objects (files) | Strongly consistent writes and deletes | Any; sized for large objects |
| Durable Objects | Named instance with attached storage | Strongly consistent, transactional per object | High on one key β this is the point |
| Hyperdrive | Pooled access to your Postgres/MySQL | Whatever your database gives you | Whatever your database gives you |
Everything below is that table applied.
Workers KV: fast reads, slow truth
KV stores a value against a key and caches it in each Cloudflare location that reads it. Cloudflare is explicit about the cost: KV achieves high performance by being eventually consistent, and changes may take up to 60 seconds or more to be visible in other global network locations as their cached copies time out.
// a hot read: served from the local cache, sub-millisecond
const flags = await env.CONFIG.get("feature-flags", { type: "json" });
// cacheTtl controls how long this location caches it.
// Minimum 30, default 60. Higher = faster and cheaper, staler.
const news = await env.NEWS.get("feed:policy", { type: "json", cacheTtl: 300 });
A hot read is served from that local cache. A cold read has to go to the central store and is much slower β which is why KV suits a handful of very frequently read keys, not a long tail of rarely-read ones.
The limits that decide things: keys up to 512 bytes, values up to 25 MiB, metadata up to 1,024 bytes, 1,000 namespaces per account, 1,000 operations per Worker invocation, and β the important one β roughly one write per second to the same key. Writes to different keys are not throttled that way.
The read-modify-write trap
This is the single most common agent-written KV bug:
// BAD: two concurrent requests both read 7, both write 8. One hit is lost.
const current = Number((await env.RATE_LIMITS.get(key)) ?? "0");
await env.RATE_LIMITS.put(key, String(current + 1), { expirationTtl: 60 });
if (current + 1 > 100) return new Response("429", { status: 429 });
There is no atomic increment in KV, and a busy key exceeds the one-write-per-second budget anyway. Cloudflare says plainly that a write-heavy workload updating the same key tens or hundreds of times per second is not a good fit. Use the platformβs rate-limiting binding instead:
// wrangler.jsonc
{
"ratelimits": [
{ "name": "API_LIMITER", "namespace_id": "1001",
"simple": { "limit": 100, "period": 60 } } // period must be 10 or 60
]
}
// key can be any string β combine the actor and the resource
const { success } = await env.API_LIMITER.limit({ key: `${userId}:${pathname}` });
if (!success) return new Response("Rate limit exceeded", { status: 429 });
One caveat worth knowing: that limit counts within a single Cloudflare location, not globally. If you need one exact global budget per customer, that is a Durable Object.
D1: when you need to ask questions
D1 earns its place the moment you want to filter, join, sort or aggregate. KV can only fetch by exact key or list() by prefix β it has no query engine. A list() call on every request to find the keys you want is a smell that the data wanted a table.
// D1 can answer this; KV structurally cannot
const { results } = await env.DB
.prepare(`SELECT topic, COUNT(*) AS n FROM articles
WHERE published_at > ? GROUP BY topic ORDER BY n DESC`)
.bind(since)
.all();
Its shape constraints: 10 GB per database (not raisable), 2 MB per row, single-threaded, and billing on rows read rather than rows returned.
R2: files, and the egress argument
R2 is object storage with an S3-compatible API. The commercial headline is that egress to the Internet is free; you pay $0.015 per GB-month for standard storage, $4.50 per million Class A operations (writes, lists) and $0.36 per million Class B operations (reads), with a free tier of 10 GB-month, 1 million Class A and 10 million Class B per month.
For your audio bucket β podcast files downloaded over and over β free egress is the entire reason to be on R2 rather than S3.
// serving from R2 with the right headers, straight from the object
const obj = await env.AUDIO.get(`episodes/${slug}.mp3`);
if (!obj) return new Response("Not found", { status: 404 });
const headers = new Headers();
obj.writeHttpMetadata(headers); // content-type, cache-control, etc.
headers.set("etag", obj.httpEtag);
return new Response(obj.body, { headers }); // streams; never buffered in memory
If an agent wrote const buf = await obj.arrayBuffer() and then returned that, it has loaded the whole file into the Workerβs memory for no reason. obj.body is a ReadableStream β pass it straight through.
Presigned URLs
A presigned URL is a time-limited link, signed with your R2 S3 API credentials, granting temporary access to one object without exposing those credentials. Cloudflareβs docs allow a timeout from 1 second to 7 days. Two things to remember: treat the URL as a bearer token, because anyone holding it can perform that operation until it expires, and it only works against the S3 API domain, not a custom domain.
Durable Objects: the place read-modify-write is safe
A Durable Object has a globally-unique name, so every request addressed to that name reaches the same single instance anywhere in the world, its execution is single-threaded, and its attached storage is strongly consistent and transactional. That combination is what neither KV nor D1 gives you.
Reach for one when several clients must agree on one number or one lock: a global per-customer quota, a job claim, a live progress counter, a WebSocket room.
Hyperdrive: keep the Postgres you already have
Workers are short-lived and everywhere, which makes them a terrible client for a database expecting long-lived pooled connections. Hyperdrive keeps those pools warm and adds default-on caching for popular queries, so a Worker can reach an existing Postgres or MySQL β on Azure, Neon, AWS and elsewhere β with your existing drivers and ORMs and no rewrite. If the IFF research data already lives in Postgres, Hyperdrive is a much smaller change than copying it into D1.
Your five cases, worked
Sessions β KV. Cloudflare recommends KV for session data, credentials and configuration precisely because these are read at thousands of requests per second, rarely written, and do not need to be immediately consistent. The catch is revocation: deleting the key can take up to 60 seconds to be visible everywhere. Keep sessions short-lived, store an expiry inside the value and check it on read, and if you need genuinely instant logout, move the session record to a Durable Object.
OAuth state β KV with expirationTtl. A state nonce is written once, read once seconds later, then discarded. The write and the read normally land in the same location, so eventual consistency rarely bites β but do let KV expire it rather than deleting it yourself.
await env.OAUTH_STATE.put(state, JSON.stringify({ redirect, pkce }), {
expirationTtl: 600, // 10 minutes; KV deletes it for you
});
Rate limits β the rate-limiting binding, or a Durable Object. Not KV. Your existing rate limits namespace is worth auditing first.
News cache β KV for the rendered payload, D1 for the queryable articles. The cron Worker refreshes both; page views only touch KV, with a generous cacheTtl. The data is already minutes old by design, so staleness costs nothing.
Audio and exports β R2, with metadata in D1.
// export job: R2 holds the bytes, D1 holds the state the UI polls
const key = `exports/${jobId}.csv`;
await env.EXPORTS.put(key, csvStream, {
httpMetadata: { contentType: "text/csv",
contentDisposition: `attachment; filename="responses.csv"` },
});
await env.DB.prepare("UPDATE export_jobs SET status = ?, r2_key = ? WHERE id = ?")
.bind("ready", key, jobId)
.run();
What to check in your own code
- Open your
rate limitsKV namespace. If a Worker doesgetthenputon the same key, that is a lost-update race β replace it with the rate-limiting binding or a Durable Object. - Grep for
.list()on a KV namespace inside a request handler. KV has no query engine; either redesign the key or move the data to D1. - Look for base64 or large JSON strings in D1 columns or KV values. Anything over a megabyte or two belongs in R2 β KV caps a value at 25 MiB, D1 at 2 MB per row.
- Grep for
arrayBuffer()on an R2 object that is then returned. Passobj.bodythrough instead and addobj.writeHttpMetadata(headers). - Check that every KV
putfor a short-lived token usesexpirationTtlrather than a later manual delete. - Check your session flow: is there an expiry inside the stored value, and are you relying on a KV delete for logout? If so, the old session can work for up to a minute.
- Set
cacheTtlexplicitly on your hottest KV reads β the default is only 60 seconds, and for a 15-minute news cache you are paying for cold reads you do not need. - For anything still in a conventional Postgres, ask whether Hyperdrive is a smaller change than a migration to D1.
An agent implements per-user API rate limiting with `await env.RATE_LIMITS.get(key)`, `+ 1`, then `put`. What is wrong?
Sources
- Choose a data or storage product website Cloudflare's own comparison table and the recommendation to use Workers KV for session data, credentials and configuration.
- Workers KV Β· How KV works website Source for eventual consistency, the up-to-60-seconds propagation figure, cacheTtl, and the warning against write-heavy workloads.
- Workers KV Β· Limits and pricing website 25 MiB value size, 512-byte keys, 1 write per second to the same key, 1,000 operations per invocation.
- R2 Β· Pricing and presigned URLs website Confirms egress to the Internet is free, and gives Class A/B operation and storage prices.
- Durable Objects Β· Overview website Globally-unique named objects combining compute with strongly consistent, transactional storage.