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. 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. This post walks through 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. According to Cloudflare's page on how KV works, 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 eventual consistency is 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. Blocking an abusive IP is a single KV write with an expiry of a few days, and every request checks for that key before doing anything else.
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: an INSERT ... ON CONFLICT ... DO UPDATE on a table keyed by account and date, which creates the day's row on the first request and increments it on every request after.
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, so every check-and-increment for that account is strictly ordered. The object reads the day's count from its storage, refuses the request if the count has reached the plan's limit, and otherwise increments and saves it before answering.
On a Free account, the 500th request of the day always sees a count of 499 and the 501st is refused, 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, we pick by asking whether a race could produce a wrong answer, not by raw speed (in isolation, KV reads beat both of the others): 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 about Cloudflare's Rate Limiting binding?
Cloudflare also has a native rate limiter for Workers, which went GA in September 2025. You declare a [[ratelimits]] binding with a limit and a period of either 10 or 60 seconds, then call limit() with whatever key you want to throttle (an IP, an API key, a path) and get back a pass or fail. No Durable Object class, KV namespace or D1 table is involved.
The tradeoff is in how it counts. Per Cloudflare's binding docs, limits are tracked per Cloudflare location, and the API is described as permissive and eventually consistent, not intended for exact accounting. The same key hitting two locations in one window is two buckets, so more requests can get through than the configured limit.
That makes it a good fit for short-window abuse throttling: a login form, a password-reset endpoint, a search box someone's script found. It doesn't fit a daily per-account quota, which has to be exact, span a whole day, and be one number across every location. We don't use the binding at all; corsproxy.dev has no [[ratelimits]] binding, and the quota lives in the Durable Object above.
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, our simpler open-source Go relay (a separate program, not the code behind corsproxy.dev, with no accounts or daily quotas) runs anywhere you can run a Go binary; see self-host vs managed for that tradeoff instead. The daily quota and the SSRF checks we cover in how a CORS proxy can be abused are both enforced at the edge for the same reason: Cloudflare's platform makes "check this on every request, cheaply" a reasonable default instead of a performance tax.