You store 500 GB of user-uploaded video and serve about 20 TB a month. On S3 that is roughly $11 of storage and around $1,700 of egress. The storage line is a rounding error; the transfer line is the bill.
That single asymmetry is why R2 exists, and it is worth understanding precisely rather than as a slogan, because there are workloads where R2 is the more expensive choice.
The two pricing shapes
S3 (and most object stores): cheap storage, cheap operations, expensive egress. Roughly $0.023/GB/month stored, and $0.09/GB out to the internet after the first free tier. Traffic through CloudFront is cheaper but still charged.
R2: slightly cheaper storage, no egress charge at all, and operations priced to matter. About $0.015/GB/month stored; class A operations (writes, lists) about $4.50 per million after a free million; class B (reads) about $0.36 per million after ten million free. Egress is $0: to anywhere, including to another cloud.
So the question is never "which is cheaper". It is "is my workload dominated by bytes out or by request count".
The arithmetic
Take that 500 GB / 20 TB video workload.
- S3: 500 GB × $0.023 = $11.50 storage, 20,000 GB × $0.09 = $1,800 egress. Call it $1,811/month.
- R2: 500 GB × $0.015 = $7.50 storage, $0 egress. Say 5 million reads a month: (5M − 10M free) → free. Call it $7.50/month.
Now a different shape: an app storing 10 GB of small JSON blobs, read 200 million times a month, serving 400 GB.
- S3: $0.23 storage, $36 egress, ~$80 in GET requests. Call it $116.
- R2: $0.15 storage, $0 egress, (200M − 10M) × $0.36/M = $68 in class B. Call it $68.
R2 still wins there, but the gap is a fifth of the first example, and if the same 200 million reads served only 40 GB (tiny objects, cache misses), R2's operations line would dominate and the two would be close. Push it further (a billion tiny reads) and R2 loses.
The rule of thumb: R2 wins on bytes, S3 competes on requests.
Where R2 wins decisively
- User-generated media served to the public. Images, video, audio, downloads. Bytes dominate, and Cloudflare's cache in front of a custom domain means most requests never touch R2 at all.
- Anything another cloud will read. Egress to a different provider is where S3 pricing hurts most, and it is $0 on R2. This is the "data gravity" tax the zero-egress model exists to remove.
- Backups and archives you might one day have to restore. The cost of getting data out is the cost you discover during an incident.
- Datasets, model weights, artefacts: large objects, read repeatedly, possibly from outside Cloudflare.
Where R2 loses, or does not help
- Enormous request volumes on small objects. Class A and B operations are real money at scale. Count requests before you migrate.
- Deep AWS integration. If Lambda, Athena, Glue and EventBridge already read your bucket, staying in S3 avoids cross-cloud latency and a pile of glue.
- Features beyond the common S3 surface. Object Lock, storage classes, Glacier-style archival, S3 Select, some checksum modes and ACLs are either absent or different. R2 is S3-compatible, not S3.
- You are already inside CloudFront with a committed-use discount. Negotiated egress rates change the arithmetic.
The migration is mostly a config change
R2 speaks the S3 API, so the SDK stays:
const client = new S3Client({
region: "auto", // R2 has one global region; a real AWS region fails the signature
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId, secretAccessKey },
});
Two things that catch people:
region: "auto". Sendingus-east-1produces a signature error whose message does not mention the region.- Checksums. Recent AWS SDK versions send additional integrity headers by default that some S3-compatible providers reject. If uploads start failing after an SDK upgrade with an opaque 400, this is the first thing to check.
For bulk data movement, Cloudflare's Super Slurper copies an existing bucket across, and Sippy migrates lazily on first read: you pay S3 egress once, for the objects that are actually requested.
Do not forget the cache
R2's zero egress is the floor, not the ceiling of the saving. Put a custom domain in front of a public bucket and Cloudflare's CDN serves repeat requests from cache, which also removes them from your class B operations count.
Set the cache headers when you write the object:
await putObject({
key,
body,
contentType: "image/webp",
cacheControl: "public, max-age=31536000, immutable",
});
immutable is honest only if the key never changes meaning. Include a hash or a
uuid in the key so a new version is a new key, and you never have to purge
anything.
Before you decide
Get four numbers for a real month:
- GB stored (and the growth rate).
- GB transferred out.
- Write and list operations.
- Read operations, and what fraction a CDN would absorb.
Then price both. If bytes out dominate, R2 is not a close call. If requests dominate and the objects are tiny, do the multiplication properly before you move anything: the migration is easy, but so is being surprised by an operations bill that nobody modelled.