If your app stores files that users repeatedly download — avatars, exports, video, generated PDFs — the storage line on your bill is almost never the problem. Egress is. Amazon S3 charges per gigabyte leaving the network, Cloudflare R2 charges nothing for egress and makes its money on storage plus operations, and Backblaze B2 gives you free egress up to a multiple of what you store and bills beyond that. Pick based on your read-to-store ratio, not on the per-GB storage rate everyone quotes.
That ratio is the whole decision, and most teams never measure it before choosing.
What actually drives an object storage bill?
Four meters run at once, and only the first is the one people compare:
- Storage — GB-months sitting at rest.
- Egress — bytes leaving the provider's network toward the public internet.
- Operations — writes/lists (usually "Class A") and reads (usually "Class B"), billed per million.
- Anything in front of it — CDN requests, image transforms, function invocations.
For a backup target, storage dominates and the cheapest per-GB wins. For user uploads served back to browsers, egress dominates and can exceed storage by an order of magnitude — a 50 GB bucket whose files get pulled a hundred times a month is a 5 TB egress bill attached to a rounding-error storage bill.
Here's the shape of each provider as of mid-2026. I'm deliberately describing pricing models rather than quoting rates, because rates move and a stale number in a blog post is worse than no number:
| Amazon S3 | Cloudflare R2 | Backblaze B2 | |
|---|---|---|---|
| Egress to internet | Per GB, the dominant cost at scale | None | Free up to a multiple of stored data, per GB above that |
| Egress to own CDN | Free to CloudFront | N/A (Cloudflare CDN is in front) | Free to Cloudflare via the Bandwidth Alliance |
| Storage rate | Lowest of the three at list | Middle | Lowest of the three at list |
| Operations | Billed, cheap | Billed, Class A noticeably pricier than Class B | Billed, with a free daily allowance |
| S3 API compatibility | The reference | High, with gaps | High, with gaps |
| Storage classes / lifecycle | Deep (IA, Glacier tiers) | Shallow | Moderate |
| Regional control | Explicit regions | Location hints, not hard regions | Explicit regions |
The uncomfortable part of that table for AWS shops: S3's cheap storage is real, and its egress is what funds it.
Takeaway: compare providers on egress-to-storage ratio for your actual traffic, because that ratio is the only axis where the three differ by more than a small multiple.
When is S3 still the right call?
More often than the "R2 has no egress fees" discourse suggests.
Keep uploads on S3 when your bytes mostly don't leave AWS — a bucket read by ECS tasks, Lambda, Athena, or SageMaker in the same region pays no internet egress at all. Same-region service-to-service traffic is the case S3's pricing is built for, and moving that bucket to another provider means you now pay for the round trip in latency and, on the way in, in cross-cloud transfer.
Keep it on S3 too when you need what only S3 has: lifecycle transitions into archival tiers, Object Lock for compliance retention, bucket-level replication rules, event notifications wired into the rest of AWS, or the long tail of tooling that assumes real S3 semantics. If your compliance story includes "objects are immutable for seven years," S3 is the one with the mature answer and the auditor-friendly paper trail.
Takeaway: if your read traffic terminates inside AWS, egress-free storage saves you nothing and costs you integration.
When do egress fees justify moving?
Run this before you argue about it. Pull a month of CloudFront or S3 access logs and get bytes-out against bytes-stored:
-- Athena over S3 server access logs: monthly bytes served, top prefixes
SELECT
regexp_extract(key, '^([^/]+)/', 1) AS prefix,
count(*) AS requests,
sum(bytessent) / 1024.0 / 1024 / 1024 AS gb_sent
FROM s3_access_logs
WHERE operation = 'REST.GET.OBJECT'
AND parse_datetime(requestdatetime, 'dd/MMM/yyyy:HH:mm:ss Z')
>= current_date - interval '30' day
GROUP BY 1
ORDER BY gb_sent DESC
LIMIT 20;
Divide monthly GB sent by GB stored. My rough decision line, from doing this on several small products:
- Under ~2x — egress is noise. Stay where you are.
- 2x to ~10x — worth putting a CDN in front and re-measuring. Caching at the edge often deletes the problem more cheaply than a migration does, and S3-to-CloudFront origin fetches don't incur internet egress.
- Over ~10x, with a low cache hit ratio (large unique files, signed one-time downloads, ML artifacts) — this is where zero-egress pricing genuinely changes the bill, because a CDN can't cache what's never requested twice.
That last category is the honest answer to "when should I move." If you want zero-egress object storage with an S3-compatible API and a CDN already in the same network path, Cloudflare R2 is the one that removes the bandwidth line from the bill entirely. If you want the cheapest at-rest storage for large archives with generous but bounded free egress, Backblaze B2 is the one that keeps cold data cheap without archival-tier retrieval games.
Takeaway: a CDN in front of S3 beats a migration for cacheable traffic; migration wins when every download is unique.
Why does the AWS SDK break against R2 and B2?
This is the part that eats an afternoon. The AWS SDK for JavaScript v3 started sending flexible checksums (CRC32) by default on requests, and S3-compatible providers that haven't implemented those headers reject them. You get something unhelpful like:
NotImplemented: Header 'x-amz-checksum-crc32' with value '...' not implemented
or a bare 501 on what was, five minutes ago, a working PutObject. Nothing about your credentials or bucket changed — the SDK's defaults did. The fix is to make checksums opt-in:
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
},
// Required for non-AWS S3-compatible endpoints that reject CRC32 headers
requestChecksumCalculation: "WHEN_REQUIRED",
responseChecksumValidation: "WHEN_REQUIRED",
});
await s3.send(new PutObjectCommand({
Bucket: "uploads",
Key: "u/42/avatar.png",
Body: bytes,
ContentType: "image/png",
}));
Two more compatibility traps in the same family. Presigned PUT URLs generated by a checksum-happy SDK embed a signed header the browser never sends, so the upload fails signature validation at the edge rather than in your code — generate presigned URLs with the same WHEN_REQUIRED client. And CORS is configured per provider, not per SDK: a browser upload that works in curl and fails in the browser is a bucket CORS rule, every time.
Takeaway: "S3-compatible" means the API surface, not the SDK defaults — pin checksum behavior explicitly the moment you point an AWS SDK at a non-AWS endpoint.
What does a low-regret setup look like?
Keep the provider swappable so this decision stays cheap to revisit:
- Talk to storage through one module that exposes
put,get,presignPut,presignGet,delete. Never let a bucket name or an endpoint URL leak into request handlers. - Store the object key in your database, never a full URL. URLs change when providers do; keys don't.
- Serve through your own domain via a CDN from day one. Then a provider change is an origin change, not a link-rot event across every row you've ever written.
- Tag or prefix by workload (
uploads/,exports/,backups/) so the query above can tell you which workload is generating egress. Aggregate bucket totals hide the one prefix that's actually the bill.
Honest drawbacks, since every tool here has one: R2's operation pricing punishes chatty small-object workloads and its lifecycle/archival story is thin, B2's free egress is bounded by your stored volume so a small bucket with viral traffic still pays, and S3 charges you for the privilege of leaving.
FAQ
Is Cloudflare R2 actually free to download from?
Egress to the internet is not billed, but storage and per-operation charges are. A workload with millions of tiny reads and writes can cost more on R2 than on S3 despite paying nothing for bandwidth.
Can I use the AWS SDK with Cloudflare R2 or Backblaze B2?
Yes — both expose S3-compatible APIs and work with the standard AWS SDKs by overriding the endpoint. On AWS SDK for JavaScript v3 you must also set requestChecksumCalculation and responseChecksumValidation to WHEN_REQUIRED, or uploads fail with a not-implemented checksum header error.
How do I know if S3 egress fees are worth migrating away from?
Divide monthly GB served by GB stored. Under about 2x, egress is noise; above about 10x with poor CDN cacheability, zero-egress providers meaningfully change the bill.
Bottom line
If your objects are read mostly by other AWS services, stay on S3 — you're not paying internet egress and you'd lose lifecycle tiers, Object Lock, and event integration for nothing. If your objects are served to browsers and cache well, put a CDN in front of S3 and re-measure before migrating; that alone resolves most egress complaints. If every download is unique and large enough that caching can't help, R2's zero-egress pricing is the one that removes the line item, and B2 is the better fit when cold storage volume dominates and your egress stays within its free multiple. Whatever you pick, keep object keys — not URLs — in your database, so the next re-measurement is a config change instead of a migration.
Top comments (1)
Spot on about the read-to-store ratio. For smaller creators or niche bloggers using AI to generate assets, egress fees often go unnoticed until a specific post goes viral or an asset is linked elsewhere. I’ve found that even before considering a migration, just auditing how many 'unique' assets are being served vs cached can save a lot of headaches. R2 is definitely tempting for those one-off generated files that CDNs can't help with.