Cloudflare KV vs D1 vs Durable Objects, in production
Our CORS proxy runs entirely on Cloudflare Workers, and it uses all three of Cloudflare's storage primitives — KV, D1, and Durable Objects — at the same time, for different pieces of the same request. That's not indecision. Each one makes a different tradeoff between consistency and speed, and picking the wrong one either corrupts your data or wastes money keeping it correct. Here's which job we gave each one, and why.
Three primitives, three different jobs
A single proxied request through corsproxy.dev touches all three: a KV read to look up the API key, a Durable Object call to check and increment the daily quota, and a D1 write to record the usage for the dashboard. None of them is a drop-in replacement for either of the others — they sit at different points on the consistency-versus-latency curve, and the request pipeline uses each one where its specific tradeoff is the right one.
KV: fast and global, and fine with being briefly wrong
Workers KV replicates a value to Cloudflare's data centers lazily, as they read it. Cloudflare's own docs on how KV works are direct about the cost of that: a write is visible immediately at the location that made it, but other locations can keep serving a stale (or missing) value for up to 60 seconds while their cached copy expires. That's eventual consistency, not strong consistency — and it's the right trade for anything we read far more often than we write.
That covers most of what a request needs to get going: the user record, the API key lookup, the session token, and the IP/key blocklist used by our bot-detection middleware. A blocked IP is a KV write with a TTL, nothing fancier:
// Block an abusive IP for 7 days
await env.BLOCKLIST.put(
`blocklist:ip:${ip}`,
JSON.stringify({ reason, blocked_at: Date.now() }),
{ expirationTtl: 60 * 60 * 24 * 7 }
);
// Checked on the next request — if it takes a few seconds to
// propagate to every edge location, that's an acceptable cost
const blocked = await env.BLOCKLIST.get(`blocklist:ip:${ip}`);
If that block takes a few seconds to reach every edge location, the worst case is a handful of extra requests slip through from an IP we just flagged. That's a fine trade for the latency KV gives back on every read in between.
D1: when you need a transaction, not a cache
Usage history is a different problem: every proxied request has to bump a per-day counter, and the dashboard needs to sum it back up by date. We used to do this in KV — and it meant five separate key reads and five writes per request (account total, per-key total, bandwidth, errors, latency), each one a read-modify-write race with every other request from the same account.
D1 is Cloudflare's SQLite-backed database for Workers, and it turns that into one atomic statement:
-- simplified from our actual usage_daily upsert
INSERT INTO usage_daily (user_id, date, requests)
VALUES (?, ?, 1)
ON CONFLICT(user_id, date) DO UPDATE SET
requests = requests + 1;
One UPSERT instead of a read-then-write per column, and the dashboard's 30-day chart is one SELECT instead of fanning out over thousands of log rows. D1 is still not the layer that decides whether a request is allowed — it records what happened, after the fact, for a human to read later. Nothing in the hot path depends on it being instantly consistent.
Durable Objects: the one place staleness breaks the contract
Daily rate limiting is the one check in this pipeline where "eventually correct" isn't good enough. If two requests from the same account land on different Cloudflare locations within the same second, and the limit check reads a count that hasn't caught up with the other request's increment yet, both can get through — even though the first one alone should have hit the cap. KV's consistency model allows exactly that race. D1 can serialize a single UPSERT, but a parallel flood of requests hammering the same row still means contention, and D1 isn't built to be a low-latency per-request gate at that volume.
A Durable Object instance is single-threaded: every request routed to the same instance queues and runs one at a time, with its own private, transactional storage. Cloudflare's docs describe this as the point of the primitive — strong consistency by construction, because there's no concurrent access to race against in the first place. We address one instance per account (idFromName(userId)), so every check-and-increment for that account is strictly ordered:
// simplified from the real RateLimiter Durable Object
export class RateLimiter {
async fetch(request) {
const { dailyLimit } = await request.json();
let used = (await this.state.storage.get('used')) ?? 0;
if (used >= dailyLimit) {
return Response.json({ allowed: false, used });
}
used += 1;
await this.state.storage.put('used', used);
return Response.json({ allowed: true, used });
}
}
The 100th request to pass this check always sees used = 99, no matter how many other requests from the same account are in flight at the same moment. That serialization is also why we don't use a Durable Object for everything — it's a single instance handling one request at a time, which is the wrong shape for data that's read far more than it's written. The user-facing side of this — what counts against your quota, what a 429 looks like, when it resets — is covered in rate limits and quotas, explained; this post is about why the gate itself had to live somewhere other than KV or D1.
A rule of thumb
After building on all three, the question that actually decides it isn't "which is fastest" — in isolation, KV reads beat both of the others. It's this: if two requests racing each other could produce a wrong answer, it's a Durable Object. If you need to query or aggregate across rows later, it's D1. Otherwise, if reads vastly outnumber writes and a few seconds of staleness is harmless, it's KV. Quota enforcement fails the first test. Usage history fails the second but not the first. Everything else — key lookups, sessions, blocklists — is comfortably in KV's lane.
What this costs you
None of this is free of tradeoffs. A Durable Object call is an extra network hop before the proxy fetch even starts, and it's billed differently from a KV read — fine for a per-account daily counter, not something you'd want gating every field read in a chatty API. D1 is newer than KV and Durable Objects and comes with its own limits on database size and query shape that are worth checking against your own write volume before you commit to it. And all three assume you're fine running on Cloudflare's platform in the first place — if you'd rather not, the open-source Go core behind corsproxy.dev runs anywhere you can run a Go binary, with none of these primitives required; see self-host vs managed for that tradeoff instead. Rate limiting and the SSRF defenses we cover in how a CORS proxy can be abused are both edge-enforced for the same reason: Cloudflare's platform makes "check this on every request, cheaply" a reasonable default instead of a performance tax.