Cloudflare's three storage primitives — KV, R2, and Cache API — appear together in the documentation and share the same runtime, which creates the impression that they are alternatives to the same problem. They are not. Each was built with a different architecture, for a different workload, with a different cost model. Using the wrong one is not only inefficient: in some cases, it simply doesn't work.
The confusion is understandable. All three "store data". But the relevant distinction isn't what they do in the abstract — it's how each performs under real traffic, what they cost at scale, and what guarantees they offer.
KV: the global store for small, frequently read data
KV is a globally distributed key-value store. Writes go to a central store and propagate to more than 300 PoPs in up to 60 seconds. Reads arrive in sub-milliseconds if the key is cached in the nearest PoP, or ~20ms if it needs to be fetched from the central store.
The cost model favors volume readings: $0.50 per million readings after the first 10 million free monthly readings. Writes cost the same $0.50 per million, but with only 1 million free. The maximum limit per value is 25MB.
KV works well for rarely written and massively read data: product configuration, feature flags, templates, content indexes, session tokens with TTL. It works poorly for anything that changes frequently or requires immediate consistency — eventual consistency with a window of up to 60 seconds and the absence of atomic operations are real limitations, not documentation details.
R2: object storage without egress fee
R2 is Cloudflare's object storage, functional equivalent to S3. It was built for large files — images, videos, backups, data exports, static assets. The competitive difference in relation to S3 is the absence of an egress fee: you do not pay to transfer data from R2 to the internet, which in S3 is one of the most painful lines on the bill.
The storage cost is $0.015/GB-month. Each read operation (GET) in R2 counts as a request — there is no global automatic cache like in KV. If you GET an R2 object in each Worker request, you are paying for each request plus the latency time of each GET. This makes R2 unsuitable for high read frequency data per request.
The correct combination is to use R2 for the file and KV (or the Cache API) for the index or cached version. A Worker that serves images can store the binary in R2 and maintain a JSON in KV with signed URLs, metadata and HTTP headers — so frequent reading accesses KV in sub-milliseconds, and R2 is only touched for uploads and generating URLs.
The limit per object in R2 is not the same as in KV: multiple GB files are supported. For KV with its maximum of 25MB per value, R2 is the natural destination for any data that exceeds this threshold.
Cache API: the HTTP response cache per PoP
The Cache API stores Response objects in the HTTP cache of the current PoP. It's free, with no operation quotas, and operates as a caching layer over HTTP responses — not as a shared state store.
The critical detail that differentiates it from KV is the scope: Cache API is per PoP, not global. A cache hit in the São Paulo PoP does not affect the Frankfurt PoP. If a Worker in Frankfurt never received a request for that URL, the cache will be cold in Frankfurt, regardless of how many times São Paulo served that response from the cache.
Another limit: content in the Cache API can be evicted by the PoP at any time due to LRU pressure. There is no guarantee of persistence between requests — the next request for the same resource may encounter the cold cache, even if the previous request has populated it.
The Cache API works well for deduplicating requests to third-party APIs within a short period of time — you fetch it once, cache Response for 30 seconds, and subsequent requests in the same PoP reuse the response without calling the upstream API. It also serves for caching computationally expensive responses that are requested in bursts in the same PoP.
What doesn't work: using Cache API as shared state between Workers or between regions. Two Worker instances in different PoPs will not see the same cache state. For shared state, KV is the way.
The anti-pattern matrix
Using R2 for application configuration is the most common mistake among teams arriving from S3. On S3, it's common to store config.json in a bucket and read it at application startup — the server lasts hours or days, so a GET every restart is cheap. In Workers, each isolate can be created and destroyed frequently. Each GET to R2 has the latency of a network request and counts as a charged operation. For configuration, KV with module-level caching is the correct model.
Using KV for video files or large datasets is the other extreme. The 25MB limit per value already creates immediate problems for any real-sized asset. But beyond the limit, the cost of writing to KV is prohibitive for files that arrive via user upload frequently. R2 at $0.015/GB-month is several orders of magnitude cheaper for large file storage.
Using Cache API for any type of state that needs to be consistent across PoPs is a guaranteed source of erratic behavior. The typical symptom is a bug that appears "sometimes" — because the PoP that served the previous request had the cache populated, and the PoP that served this one did not. Cache API does not replace KV for global data.
How to choose
The decision starts with the type of data and the frequency of access. Small data, read many times per second, needs global distribution: KV. Large file, written by upload and read with low to moderate frequency: R2. HTTP response that changes rarely and may be local to the PoP: Cache API.
The cost confirms or discards the choice. If the volume of writes is high, KV becomes expensive. If the volume of individual GETs per file is high, R2 becomes expensive and slow. If you need cross-PoP consistency, Cache API won't do.
Combining the three is the correct model
The pattern that appears most often in mature architectures with Workers is deliberate combination. R2 stores the binary. KV stores the index, metadata and short-lived signed URL. Cache API deduplicate burst requests to the same PoP. Each layer does what it was designed to do, and the result is a storage stack that performs well and costs what it should cost.
The trap is trying to simplify to a single primitive. Cloudflare offers all three because each solves a different problem. Understanding the boundary between them is what separates an implementation that works in development from one that survives real traffic.
Also read
- Cloudflare KV: What does globally distributed mean when you need to write
- Cache invalidation in KV: the problem that no one solves elegantly
- Cloudflare Load Balancing and Geo Steering: when DNS becomes an intelligent traffic layer
- KV in production: the patterns that work and those that are misleading at the beginning
- [KV for rate limiting, feature flags and distributed configuration: where it works and where it breaks7
- Workers + D1 + KV + R2: composing bindings in the same service
