Cloudflare R2 vs S3 vs Firebase Storage: $0 vs $90 vs $108 for 1TB of Downloads a Month — Choosing Storage by Egress Cost

When you add image upload to a Flutter app, most people search some version of “S3 vs Firebase Storage pricing” or “R2 storage cost per GB.” The per-GB storage numbers in every comparison table turn out to be roughly the same, so the conclusion is usually “let’s just use whatever we’re already familiar with.” But once the service grows and users start scrolling through profile pictures and feed images every day, the biggest line item on the bill is not storage — it’s download (egress) cost. This post proves that with real pricing.
Key takeaway: Storage cost is a few cents per GB across the board, so the three services barely differ there. But egress (download) cost is off by orders of magnitude. Cloudflare R2’s egress is structurally $0, AWS S3 charges $0.09/GB, and Firebase Storage charges roughly $0.12/GB after the first 100GB/month free. At just 1TB of monthly download traffic, that’s $0 for R2, about $90 for S3, and about $108 for Firebase Storage. For any app dealing in images or video, this gap scales linearly with traffic — and psychologically it feels exponential.
Why egress, not storage, is the real problem
The answer becomes obvious once you think about the traffic pattern that image/video storage actually sees in a Flutter app. An image is uploaded once and viewed hundreds or thousands of times. A single profile picture gets loaded once for every follower who views it; a single feed photo gets re-fetched every time someone scrolls past it or relaunches the app. In other words, writes (Put/Upload) are one-time events, but reads (Get/Download) make up the vast majority of traffic. Storage fees are charged on “how many GB you’re holding, for how long,” so they scale linearly with total storage size — but egress fees are charged on “how many GB go out, how often,” so they grow far faster in proportion to actual usage traffic.
The three pricing structures at a glance
First, here’s a summary of storage and egress pricing for each service, based on their official pricing pages (checked July 2026).
| Cloudflare R2 | AWS S3 (Standard, us-east-1) | Firebase Storage (Blaze) | |
|---|---|---|---|
| Storage | $0.015/GB-month | $0.023/GB-month (first 50TB) | $0.026/GB-month over 5GB on legacy buckets; new buckets vary by region |
| Write/change requests | Class A: $4.50/million | PUT etc.: $0.005/1,000 | Separate from Firestore; Storage’s own request charges are relatively negligible |
| Read requests | Class B: $0.36/million | GET etc.: $0.0004/1,000 | Same as above |
| Download (egress) | $0 (entirely free) | $0.09/GB (first 10TB/month) | ~$0.12/GB above 100GB/month |
R2’s per-request (operation) pricing looks more expensive than S3’s at first glance, but for read-heavy workloads like an image-viewing app, the egress line alone flips every other comparison on its head. Let’s confirm that with actual numbers below.
The free tiers, unpacked: are they really free
All three services advertise a “free tier,” but the fine print is completely different in each case.
- Cloudflare R2: On Standard storage, 10GB-month of storage is free, Class A (write) requests are free up to 1 million/month, and Class B (read) requests are free up to 10 million/month. Separately from this free tier, egress is always free regardless of storage class or traffic volume — there is simply no billing line item for download traffic leaving R2 in the first place.
- Firebase Storage (Spark/Blaze alike): On new buckets (
*.firebasestorage.app), downloads are free up to 100GB/month, and storage is free up to 5GB-month. Switching to the paid plan (Blaze) doesn’t remove this free allocation — it stays in place. The catch is that this free allocation only applies in three regions: us-central1, us-west1, and us-east1. - AWS S3: There is technically “100GB/month of free data transfer out to the internet.” But if you read the official documentation literally, that 100GB is “aggregated across all AWS Services and Regions” — a single shared pool across your entire account, every region, and every service. Whether an EC2 instance is sending out API responses, CloudFront is pulling data from S3 as the origin on a cache miss, or you’re downloading an RDS backup, it all draws from the same 100GB pool. For a sandbox account running nothing but a storage bucket, this free tier is meaningful — but for a production AWS account that already has other workloads running, it’s safer to assume this pool is effectively always exhausted.
This difference is also why the S3 and Firebase Storage numbers in the low-traffic tier of the simulation table below don’t line up the way you’d naively expect.
Real bill simulation: 100GB / 1TB / 10TB of monthly downloads
The table below calculates the monthly bill for egress traffic alone. The calculation conditions were set explicitly as follows:
- 1TB is simplified to 1,000GB (actual billing units are closer to GiB/TiB, but decimal units are used here for the purpose of comparing orders of magnitude).
- R2: Egress is always $0. (Storage and operation costs are covered in a separate section.)
- S3: Assumes the account-wide shared free tier (100GB) is already exhausted by other AWS traffic, so the first-10TB/month rate of $0.09/GB is applied directly.
- Firebase Storage: Assumes the Storage-specific free allocation (100GB/month) refills every month for this app alone, with $0.12/GB applied to the excess.
| Monthly download traffic | Cloudflare R2 | AWS S3 | Firebase Storage |
|---|---|---|---|
| 100GB | $0 | 100GB × $0.09 = $9 | Within free limit → $0 |
| 1TB (1,000GB) | $0 | 1,000GB × $0.09 = $90 | (1,000-100)GB × $0.12 = $108 |
| 10TB (10,000GB) | $0 | 10,000GB × $0.09 = $900 | (10,000-100)GB × $0.12 = $1,188 |
Read the numbers as they are: at 1TB of monthly downloads, S3 costs 90x more than R2, and Firebase Storage costs 108x more. And this gap only widens in absolute dollar terms as traffic grows. At the 10TB tier, the annual difference against R2 comes out to $10,800 for S3 and $14,256 for Firebase Storage.
For reference, S3’s rate steps down gradually past 10TB — $0.085/GB (10-50TB), $0.070/GB (50-150TB) — as part of its tiered pricing. Even so, compared against R2’s flat $0, that “discount” is essentially meaningless.
Storage and request costs are comparatively small
Assume the same app keeps 500GB of images in permanent storage:
- R2: 500GB × $0.015 = $7.5/month
- S3: 500GB × $0.023 = $11.5/month
- Firebase Storage: at the legacy rate, 500GB × $0.026 ≈ $13/month (new buckets vary by region and the official page doesn’t give a single figure — check the console estimate directly if you need certainty)
Operation costs look similar. Even for a reasonably high-traffic app doing 100,000 uploads (Class A) and 50 million reads (Class B) per month, R2’s Class A stays within the free tier (1 million/month) at $0, and Class B — 40 million requests over the 10-million-request free tier — costs just $14.4/month at $0.36/million. In other words, both storage cost and request cost stay in the tens-of-dollars range. Egress, on the other hand, jumps into three- and four-digit dollar amounts the moment traffic scales up. Egress is, in the end, the single variable that decides the comparison.
A common mistake in Flutter apps: serving the original image as-is
The pattern seen most often in Firebase Storage + Flutter codebases looks like this.
final ref = FirebaseStorage.instance.ref('posts/$postId/original.jpg');
final url = await ref.getDownloadURL();
// Loaded as-is inside a ListView
Image.network(url);
An original photo straight off a phone camera is typically 3,000×4,000px and 3-8MB in size. Serving that unresized as a feed thumbnail — where the actual displayed size is around 100×100px — sends far more bytes out as egress than necessary, every single time a user scrolls the feed. Assume 10,000 users open the app 3 times a day and each load 30 images: that’s 900,000 downloads a day, and at 3MB per image, that comes out to roughly 2.7TB a day, or about 81TB a month. Plug that straight into Firebase Storage’s pricing (roughly $0.12/GB after the first 100GB/month free) and you get a bill in the neighborhood of $10,000 a month.
Caching adds a second problem on top of this. Without a custom cache setting, Firebase Storage objects default to Cache-Control: public, max-age=3600. That means the browser/client cache expires after just one hour, so reopening the app or revisiting the same screen an hour later re-downloads the original file from scratch. Even with a client-side disk cache via a package like cached_network_image, the first load — or any load after the cache is cleared — still has to pull the entire original file, and that structural cost doesn’t go away.
A practical order of fixes
- Resize at upload time: Attach the “Resize Images” Firebase Extension, or generate multiple sizes (a 200px thumbnail, an 800px medium, and the original) via a Cloud Functions upload trigger, storing each separately. List screens should only request the thumbnail; the original should only be requested when the user opens the detail view.
- Set a long Cache-Control: Put a content hash into the image path to make the file effectively immutable, and explicitly set
Cache-Control: public, max-age=31536000, immutablemetadata. As long as the filename never changes, the file can be cached indefinitely. - Match the requested size to the display size on the Flutter side:
cacheWidth/cacheHeightonImage.networkreduces decoding cost, but it does nothing to reduce the bytes actually pulled over the network — the server has to serve a smaller file to begin with for that. Don’t conflate client-side decode optimization with egress reduction; they’re separate problems. - Put a CDN in front: Caching image requests through a Firebase Hosting rewrite or a separate CDN means repeat requests for the same image never reach the origin (Storage), reducing egress. Keep in mind, though, that the CDN itself may carry its own separate bandwidth charges.
A migration checklist for moving to R2
Because R2 exposes an S3-compatible API, an existing S3-based codebase can generally be migrated with less rework than you’d expect. It isn’t a 100% drop-in replacement, though.
1) Check for unsupported features first. R2’s S3-compatible API covers most core object operations — PutObject, GetObject, HeadObject, DeleteObject, ListObjectsV2, and multipart upload (CreateMultipartUpload/UploadPart/CompleteMultipartUpload). What it does not support includes ACLs (x-amz-acl), Object Lock/versioning, object and bucket tagging, bucket policies, notifications, logging, replication, and SSE-KMS server-side encryption. If a project relies on fine-grained IAM policies or S3 bucket policies for access control, that access model needs to be redesigned around Cloudflare’s API-token-based permission model.
2) Use Super Slurper for data migration. Cloudflare’s Super Slurper performs bulk migration into R2 from S3 and S3-compatible storage (with support for Backblaze B2, Wasabi, MinIO, DigitalOcean Spaces, and more), free of charge. The only cost incurred is R2’s own Class A operation charges generated during the migration. One item worth adding to the checklist, though: because objects may be split into multiple parts during transfer, the resulting ETag may not exactly match the original (if any code relies on ETags for integrity verification, replace that check with a separate hash comparison).
3) Presigned URL issuance works a little differently. R2 supports generating presigned URLs directly with the AWS SDK, same as S3.
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const S3 = new S3Client({
region: "auto", // R2 has no region concept, so this must be "auto"
endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: "<ACCESS_KEY_ID>",
secretAccessKey: "<SECRET_ACCESS_KEY>",
},
});
const putUrl = await getSignedUrl(
S3,
new PutObjectCommand({
Bucket: "my-bucket",
Key: "posts/123/original.jpg",
ContentType: "image/jpeg",
}),
{ expiresIn: 3600 }, // configurable from 1 second up to 7 days (604,800 seconds)
);
The Flutter client simply takes this URL from the backend and PUTs directly to it.
final res = await http.put(
Uri.parse(presignedUrl),
headers: {'Content-Type': 'image/jpeg'},
body: imageBytes,
);
The important caveat: presigned URLs only work against R2’s default r2.cloudflarestorage.com endpoint — they do not work against a custom domain. The common pattern is to split the two: uploads go through the presigned URL, and public downloads go through the custom domain.
4) Skip the origin entirely with Workers bindings. If the backend runs on Cloudflare Workers, it can bypass the S3-compatible API altogether and access R2 directly through a native binding.
# wrangler.toml
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-app-media"
export default {
async fetch(request, env) {
const url = new URL(request.url);
const key = url.pathname.slice(1);
if (request.method === "GET") {
const obj = await env.MY_BUCKET.get(key);
return obj ? new Response(obj.body) : new Response("Not found", { status: 404 });
}
if (request.method === "PUT") {
await env.MY_BUCKET.put(key, request.body);
return new Response(`Put ${key} successfully!`);
}
return new Response("Method not allowed", { status: 405 });
},
};
This approach lets you handle logic like auth, resizing, or watermarking inside the Worker while still keeping egress at a flat $0.
5) Connect a public domain. R2 buckets are private by default. In production, the standard approach is to connect a custom domain through Cloudflare DNS for public read access, rather than relying on the development-only r2.dev subdomain (which is rate-limited and not recommended for production).
When S3 or Firebase Storage is still the better choice
Egress cost alone makes R2 look like an overwhelming win, but in the following situations it’s reasonable not to bother migrating:
- An AWS organization with an already-solid IAM/security setup: If bucket policies, VPC endpoints, cross-account access, or compliance requirements built on Object Lock are already in place, the features R2 doesn’t support may make migration itself difficult — or the redesign cost may outweigh the egress savings.
- A Firebase ecosystem tightly coupled to Firestore/Auth: If fine-grained access control based on
request.auth.uidis already built with Firebase Storage Security Rules, or Storage upload triggers already wire into Cloud Functions/Extensions (thumbnail generation, video transcoding), keeping that integration intact is the better call for development velocity. Especially at the MVP/early-startup stage, where download traffic is still hovering near 100GB/month, the egress cost difference itself is small enough that it’s hard to justify the migration cost. - Maturity of CDN integration: An S3+CloudFront setup carries years of accumulated capability — signed cookies, region-based access control, Lambda@Edge, and more. R2 does integrate with Cloudflare’s CDN, but if cache invalidation and edge logic are already heavily built around CloudFront, the switching cost needs to be calculated separately.
- Existing operational knowledge and tooling: If Terraform modules, monitoring dashboards, and alerting rules are already built around AWS/GCP, the egress savings may be smaller than the engineering cost of rebuilding that operational tooling. In this case, a gradual approach — adopting R2 only for new media pipelines while leaving existing buckets in place — is the more realistic path.
Conclusion: the deciding factor is your traffic profile
Storage pricing across the three services sits within a narrow 1-3 cents per GB band — not much separates them there. The real decision comes down to how much download traffic you generate, and what you get charged for it.
- If monthly downloads stay under 100GB (an early MVP, an internal tool), the cost difference between any of the three is negligible. In that case, prioritize whichever ecosystem you’re already integrated with (Firebase Auth, AWS IAM).
- Once monthly downloads pass 1TB, the gap between R2 and the other two opens up to a double-digit multiplier. If images or video are core content in a social or commerce-style Flutter app, this is the point where switching to R2 deserves serious consideration.
- For a service pulling 10TB+ in downloads, the egress cost difference reaches tens of thousands of dollars a year — at that scale, it’s worth running actual numbers against the migration checklist above (S3-compatible API limitations, Super Slurper, presigned URLs, Workers bindings).
The numbers don’t lie. As traffic grows, one single sentence — “egress is free” — ends up deciding the entire bill.