Cloudflare R2 vs. S3 vs. Firebase Storage: Bei 1 TB Downloads im Monat zahlst du $0 vs. $90 vs. $108 – Storage-Wahl nach Egress-Kosten

Wer in einer Flutter-App einen Bild-Upload einbaut, sucht meistens zuerst nach “S3 vs. Firebase Storage Preisvergleich” oder “R2 Speicherkosten pro GB”. Und weil die GB-Preise in den Tabellen alle ungefähr gleich aussehen, fällt die Entscheidung oft nach dem Motto “nehmen wir einfach das, was wir schon kennen”. Sobald der Dienst aber wächst und Nutzer täglich Profilbilder und Feed-Fotos ansehen, ist der größte Posten auf der Rechnung nicht mehr die Speicherung, sondern die Download-Kosten (Egress). Dieser Beitrag belegt das mit den echten Preisen.
Kurzfassung: Die Speicherkosten liegen bei allen drei Diensten im Bereich weniger Cent pro GB – da gibt es kaum Unterschiede. Bei den Egress-Kosten (Downloads) liegen aber Welten dazwischen. Bei Cloudflare R2 ist Egress strukturell bedingt immer $0, AWS S3 verlangt $0,09 pro GB, und Firebase Storage berechnet nach 100 GB Freikontingent im Monat rund $0,12 pro GB. Schon bei 1 TB Download-Traffic im Monat kostet das bei R2 $0, bei S3 rund $90 und bei Firebase Storage rund $108. Je mehr eine App mit Bildern und Videos arbeitet, desto stärker wächst dieser Abstand – linear mit dem Traffic, gefühlt aber exponentiell.
Warum nicht die Speicherkosten, sondern Egress das eigentliche Problem ist
Ein Blick auf das typische Traffic-Muster von Bild-/Video-Storage in einer Flutter-App liefert die Antwort. Ein Bild wird einmal hochgeladen und Hunderte bis Tausende Male abgerufen. Ein einzelnes Profilbild wird so oft geladen, wie die Person Follower hat; ein einzelnes Feed-Foto wird bei jedem Scrollen und bei jedem App-Neustart erneut heruntergeladen. Schreiben (Put/Upload) passiert also nur einmal, während Lesen (Get/Download) den Großteil des Traffics ausmacht. Speicherkosten werden dafür berechnet, “wie viele GB wie lange vorgehalten werden” und wachsen damit linear mit dem Gesamtvolumen. Egress-Kosten dagegen hängen davon ab, “wie viele GB wie oft nach draußen gehen” – und wachsen deshalb mit der tatsächlichen Nutzung viel schneller.
Die Preisstrukturen der drei Anbieter im Überblick
Zunächst die Speicher- und Egress-Preise aller drei Dienste, wie sie auf den offiziellen Preisseiten stehen (Stand: Juli 2026).
| Kategorie | Cloudflare R2 | AWS S3 (Standard, us-east-1) | Firebase Storage (Blaze) |
|---|---|---|---|
| Speicherkosten | $0,015/GB-Monat | $0,023/GB-Monat (erste 50 TB) | Bei Legacy-Buckets $0,026/GB-Monat ab 5 GB, bei neuen Buckets abhängig von der Region |
| Schreib-/Änderungsanfragen | Class A: $4,50 pro Million | PUT etc.: $0,005 pro 1.000 | Getrennt von Firestore, eigene Storage-Anfragekosten sind vergleichsweise gering |
| Leseanfragen | Class B: $0,36 pro Million | GET etc.: $0,0004 pro 1.000 | wie oben |
| Download (Egress) | $0 (komplett kostenlos) | $0,09/GB (erste 10 TB/Monat) | rund $0,12/GB nach 100 GB im Monat |
Auf den ersten Blick wirken R2s Preise pro Anfrage (Operation) teurer als bei S3. Bei Workloads mit sehr vielem Lesen – wie einem Bild-Viewer – dreht die eine Zeile “Egress” jedoch alles um und schlägt jeden anderen Posten. Das zeigen die konkreten Zahlen im nächsten Abschnitt.
Die Freikontingente im Detail: Wie kostenlos ist “kostenlos” wirklich?
Alle drei Anbieter werben mit einem “kostenlosen Kontingent”, aber die Bedingungen unterscheiden sich fundamental.
- Cloudflare R2: Beim Standard-Speicher gibt es 10 GB-Monate Speicherung kostenlos, 1 Million Class-A-Anfragen (Schreiben) im Monat kostenlos und 10 Millionen Class-B-Anfragen (Lesen) im Monat kostenlos. Und unabhängig von diesem Freikontingent gilt: Egress ist immer kostenlos, unabhängig von Storage-Klasse und Traffic-Volumen. Für ausgehenden Download-Traffic aus R2 gibt es von vornherein keinen Kostenpunkt.
- Firebase Storage (Spark/Blaze gleichermaßen): Bei neuen Buckets (
*.firebasestorage.app) sind bis zu 100 GB Download im Monat kostenlos, Speicherung bis 5 GB-Monate. Auch nach dem Wechsel auf den bezahlten Blaze-Plan bleibt dieses Freikontingent bestehen. Ein Haken dabei: Das Freikontingent gilt nur in den drei Regionen us-central1, us-west1 und us-east1. - AWS S3: Zwar gibt es auch hier “100 GB ausgehenden Internet-Traffic im Monat kostenlos”. Die offizielle Dokumentation formuliert das aber so: Diese 100 GB werden “aggregated across all AWS Services and Regions” – also über das gesamte Konto, alle Regionen und alle Dienste hinweg zusammengezählt. Ob eine EC2-Instanz API-Antworten ausliefert, CloudFront bei einem Cache-Miss Daten vom Origin (S3) zieht oder ein RDS-Backup heruntergeladen wird – alles zehrt am selben 100-GB-Pool. In einem reinen Test-Account, in dem nur Storage läuft, ist dieses Freikontingent tatsächlich relevant. In einem produktiven AWS-Konto, in dem bereits andere Workloads laufen, sollte man aber davon ausgehen, dass es faktisch immer schon aufgebraucht ist.
Genau dieser Unterschied ist auch der Grund, warum in der folgenden Simulationstabelle die Zahlen für S3 und Firebase Storage im günstigen Traffic-Bereich so unterschiedlich ausfallen.
Die Rechnung in der Praxis: 100 GB / 1 TB / 10 TB Download im Monat
Die folgende Tabelle zeigt die monatlichen Kosten ausschließlich für Egress-Traffic. Die Rechenannahmen im Einzelnen:
- 1 TB wird vereinfachend als 1.000 GB gerechnet (auf der echten Rechnung stehen eher GiB/TiB, aber für den Größenvergleich reicht die dezimale Einheit).
- R2: Egress ist immer $0. (Speicher- und Operationskosten werden in einem eigenen Abschnitt behandelt.)
- S3: Es wird angenommen, dass das kontoweite, geteilte Freikontingent (100 GB) bereits durch anderen AWS-Traffic aufgebraucht ist. Deshalb greift durchgehend der Preis von $0,09/GB für die ersten 10 TB im Monat.
- Firebase Storage: Es wird angenommen, dass das storage-spezifische Freikontingent (100 GB/Monat) jeden Monat frisch nur für diese eine App zur Verfügung steht, und auf die übersteigende Menge $0,12/GB angewendet.
| Download-Traffic pro Monat | Cloudflare R2 | AWS S3 | Firebase Storage |
|---|---|---|---|
| 100 GB | $0 | 100 GB × $0,09 = $9 | innerhalb des Freikontingents → $0 |
| 1 TB (1.000 GB) | $0 | 1.000 GB × $0,09 = $90 | (1.000-100) GB × $0,12 = $108 |
| 10 TB (10.000 GB) | $0 | 10.000 GB × $0,09 = $900 | (10.000-100) GB × $0,12 = $1.188 |
Liest man die Zahlen direkt ab: Bei 1 TB Download im Monat ist S3 90-mal und Firebase Storage 108-mal teurer als R2. Und dieser Abstand wächst mit steigendem Traffic in absoluten Beträgen weiter. Bei 10 TB summiert sich der Unterschied zu R2 auf $10.800 pro Jahr bei S3 beziehungsweise $14.256 pro Jahr bei Firebase Storage.
Zur Einordnung: Ab 10 TB sinkt der S3-Preis stufenweise (tiered) auf $0,085/GB (10–50 TB) und $0,070/GB (50–150 TB). Verglichen mit R2s $0 bleibt das aber ein Rabatt, der praktisch keinen Unterschied macht.
Speicher- und Anfragekosten fallen dagegen kaum ins Gewicht
Nimmt man an, dieselbe App speichert dauerhaft 500 GB an Bildern:
- R2: 500 GB × $0,015 = $7,50/Monat
- S3: 500 GB × $0,023 = $11,50/Monat
- Firebase Storage: nach Legacy-Preis 500 GB × $0,026 ≈ $13/Monat (bei neuen Buckets variiert der Preis je nach Region; die offizielle Seite nennt keine feste Zahl – im Zweifel muss man den Kostenvoranschlag in der Konsole prüfen)
Auch bei den Operationskosten zeigt sich dasselbe Bild: Bei einer durchaus verkehrsreichen App mit 100.000 Uploads (Class A) und 50 Millionen Abrufen (Class B) im Monat liegt R2 bei Class A innerhalb des Freikontingents (1 Million) und damit bei $0. Class B übersteigt das Freikontingent (10 Millionen) um 40 Millionen Anfragen, was bei $0,36 pro Million $14,40 im Monat ergibt. Speicherkosten und Anfragekosten bewegen sich also beide im Bereich weniger Dutzend Dollar im Monat. Egress dagegen springt, sobald der Traffic steigt, in den drei- und vierstelligen Dollar-Bereich. Die entscheidende Variable für den Kostenvergleich ist am Ende ausschließlich der Egress.
Ein typischer Fehler in Flutter-Apps: Originalbilder unverändert ausliefern
Bei der Kombination Firebase Storage + Flutter sieht man immer wieder folgendes Muster:
final ref = FirebaseStorage.instance.ref('posts/$postId/original.jpg');
final url = await ref.getDownloadURL();
// Direkt in der ListView geladen
Image.network(url);
Ein Kamera-Originalfoto hat üblicherweise 3.000 × 4.000 Pixel und 3–8 MB Dateigröße. Liefert man das ohne Resizing als Thumbnail im Feed aus (wo es tatsächlich nur mit etwa 100 × 100 Pixeln angezeigt wird), gehen bei jedem Scrollen im Feed weit mehr Bytes über Egress raus als nötig. Bei 10.000 Nutzern, die die App dreimal am Tag öffnen und dabei jeweils 30 Bilder laden, kommen 900.000 Downloads am Tag zusammen. Bei 3 MB pro Bild sind das rund 2,7 TB pro Tag, rund 81 TB pro Monat – und das kommt schneller zusammen, als man denkt. Setzt man diese Zahl direkt in den Firebase-Storage-Preis ein (nach 100 GB Freikontingent im Monat rund $0,12/GB), landet man bei einer Rechnung von rund 10.000 Dollar im Monat.
Dazu kommt noch das Caching-Problem. Firebase-Storage-Objekte bekommen ohne eigene Cache-Konfiguration standardmäßig Cache-Control: public, max-age=3600. Das heißt: Nach einer Stunde läuft der Browser-/Client-Cache ab, und beim erneuten Öffnen der App oder beim Wiederbesuch derselben Ansicht nach einer Stunde wird die Originaldatei komplett neu heruntergeladen. Selbst mit einem clientseitigen Disk-Cache über ein Paket wie cached_network_image bleibt das Problem bestehen: Beim ersten Aufruf oder nach dem Löschen des Caches muss das Original weiterhin vollständig neu geladen werden.
Konkrete Optimierungsschritte
- Beim Upload resizen: Entweder die “Resize Images”-Erweiterung aus Firebase Extensions einbinden oder serverseitig (Cloud Functions) über einen Upload-Trigger mehrere Größen erzeugen (Thumbnail 200px, mittel 800px, Original) und jeweils separat speichern. Die Listenansicht lädt dann nur das Thumbnail, das Original wird erst beim Öffnen der Detailansicht angefragt.
- Cache-Control langfristig setzen: Über einen Content-Hash im Dateipfad wird die Datei unveränderlich (immutable). Zusätzlich explizit
Cache-Control: public, max-age=31536000, immutableals Metadaten setzen. Solange sich der Dateiname nicht ändert, ist dann dauerhaftes Caching möglich. - Anzeigegröße und angeforderte Größe in Flutter angleichen: Mit
cacheWidth/cacheHeightbeiImage.networklassen sich die Decodierungskosten senken – die tatsächlich über das Netzwerk heruntergeladene Byte-Menge sinkt dadurch aber nicht. Damit weniger Bytes ankommen, muss der Server von vornherein kleinere Dateien ausliefern. Client-seitige Decodierungsoptimierung und Egress-Einsparung sind zwei unterschiedliche Probleme – das sollte man nicht verwechseln. - Ein CDN vorschalten: Über ein Firebase-Hosting-Rewrite oder ein separates CDN lassen sich Bildanfragen cachen, sodass wiederholte Anfragen für dasselbe Bild nicht mehr bis zum Origin (Storage) durchgereicht werden – und Egress entsprechend sinkt. Dabei sollte man einkalkulieren, dass das CDN selbst zusätzliche Bandbreitenkosten verursachen kann.
Checkliste für die Migration zu R2
Weil R2 eine S3-kompatible API bereitstellt, lässt sich eine bestehende S3-basierte Codebasis mit überschaubarem Aufwand umziehen. Zu 100 % identisch ist die API allerdings nicht.
1) Zuerst prüfen, welche Funktionen fehlen. Die S3-kompatible API von R2 deckt die zentralen Objekt-Operationen größtenteils ab: PutObject, GetObject, HeadObject, DeleteObject, ListObjectsV2 sowie Multipart-Uploads (CreateMultipartUpload/UploadPart/CompleteMultipartUpload). Nicht unterstützt werden dagegen ACLs (x-amz-acl), Object Lock/Versionierung, Objekt- und Bucket-Tagging, Bucket-Policies, -Benachrichtigungen, -Logging und -Replikation sowie serverseitige Verschlüsselung per SSE-KMS. Wer Zugriffskontrolle bislang über feingranulare IAM-Policies oder S3-Bucket-Policies abgebildet hat, muss das auf das Cloudflare-API-Token-basierte Berechtigungsmodell neu übertragen.
2) Für die Datenmigration Super Slurper nutzen. Cloudflares Super Slurper migriert Daten aus S3 und S3-kompatiblem Storage (unter anderem Backblaze B2, Wasabi, MinIO, DigitalOcean Spaces) kostenlos nach R2. Berechnet werden dabei nur die Class-A-Operationskosten, die während der Migration auf R2 anfallen. Zu beachten ist aber: Weil Objekte während der Migration in mehreren Teilen übertragen werden können, muss der ETag nicht zu 100 % mit dem Original übereinstimmen – das gehört auf die Checkliste (Code, der ETags zur Integritätsprüfung verwendet, sollte stattdessen auf einen separaten Hash-Vergleich umgestellt werden).
3) Presigned URLs werden anders ausgestellt. Auch bei R2 lassen sich Presigned URLs ganz normal mit dem AWS SDK erzeugen.
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 kennt kein Regionskonzept, deshalb immer "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 }, // einstellbar zwischen 1 Sekunde und 7 Tagen (604.800 Sekunden)
);
Der Flutter-Client bekommt diese URL vom Backend und schickt einfach ein PUT dorthin.
final res = await http.put(
Uri.parse(presignedUrl),
headers: {'Content-Type': 'image/jpeg'},
body: imageBytes,
);
Wichtig dabei: Presigned URLs funktionieren nur am Standard-Endpoint r2.cloudflarestorage.com von R2, nicht über eine Custom Domain. Üblich ist deshalb eine Trennung: Upload läuft über die Presigned URL, öffentlicher Download läuft über die Custom Domain.
4) Mit Workers-Bindings den Origin ganz weglassen. Läuft das Backend auf Cloudflare Workers, kann man sogar an der S3-kompatiblen API vorbei direkt über natives Binding zugreifen.
# 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 });
},
};
So lässt sich Logik wie Authentifizierung, Resizing oder Wasserzeichen direkt im Worker unterbringen – und Egress bleibt trotzdem bei $0.
5) Eine öffentliche Domain anbinden. R2-Buckets sind standardmäßig privat. In der Produktion ist es üblich, statt der Entwicklungs-Subdomain r2.dev (mit Rate-Limit, für den Produktivbetrieb nicht empfohlen) eine Custom Domain über Cloudflare DNS anzubinden, um öffentlichen Lesezugriff einzurichten.
Wann S3 oder Firebase Storage trotzdem die bessere Wahl sind
Betrachtet man ausschließlich die Egress-Kosten, ist R2 klar überlegen. In folgenden Situationen ist ein Umzug aber nicht unbedingt sinnvoll:
- AWS-Organisationen mit bereits etabliertem, robustem IAM-/Sicherheitskonzept: Wenn Bucket-Policies, VPC-Endpoints, Cross-Account-Zugriffe oder Compliance-Anforderungen über Object Lock im Einsatz sind, kann die Migration allein wegen der von R2 nicht unterstützten Funktionen schwierig werden – und die Kosten für ein Redesign können die Ersparnis beim Egress übersteigen.
- Firebase-Ökosystem eng verzahnt mit Firestore/Auth: Wer bereits über Firebase Storage Security Rules feingranulare Zugriffskontrolle auf Basis von
request.auth.uidumgesetzt hat oder Storage-Upload-Trigger mit Cloud Functions/Extensions verbindet (Thumbnail-Erzeugung, Video-Transcoding), fährt mit dem Erhalt dieser Integration in puncto Entwicklungsgeschwindigkeit besser. Gerade in der MVP- oder frühen Startup-Phase, in der der Download-Traffic noch bei rund 100 GB im Monat liegt, ist der Egress-Kostenunterschied ohnehin so gering, dass sich der Migrationsaufwand kaum rechtfertigen lässt. - Reifegrad der CDN-Integration: Die Kombination S3 + CloudFront bringt über Jahre gewachsene Funktionen mit – signierte Cookies, regionale Zugriffskontrolle, Lambda@Edge und mehr. R2 lässt sich zwar ebenfalls mit dem Cloudflare-CDN integrieren, aber wer bereits umfangreiche Cache-Invalidierungs- und Edge-Logik rund um CloudFront aufgebaut hat, sollte die Umstellungskosten separat kalkulieren.
- Betriebs-Know-how und Tooling im Team: Sind Terraform-Module, Monitoring-Dashboards und Alarmregeln bereits auf AWS/GCP ausgelegt, kann die Ersparnis bei Egress kleiner ausfallen als der Personalaufwand, all das neu aufzubauen. Hier ist ein schrittweiser Übergang realistischer: R2 zunächst nur für neue Media-Pipelines einsetzen und bestehende Buckets unverändert lassen.
Fazit: Das Traffic-Profil entscheidet
Die Speicherkosten der drei Dienste liegen alle zwischen etwa 1 und 3 Cent pro GB – da nimmt sich keiner viel. Die eigentliche Entscheidung fällt bei der Frage, wie viel Download-Traffic anfällt und wie teuer dieser Traffic ist.
- Liegt der monatliche Download bei unter 100 GB (frühe MVP-Phase, internes Tool), macht die Wahl des Anbieters kaum einen Kostenunterschied. In diesem Fall zählt vor allem, wie gut sich der Dienst in das bestehende Ökosystem einfügt (Firebase Auth, AWS IAM).
- Sobald der monatliche Download die 1-TB-Marke überschreitet, öffnet sich zwischen R2 und den beiden anderen Diensten eine Lücke im zweistelligen Faktor. Bei Social- oder Commerce-Flutter-Apps, bei denen Bilder und Videos den Kerninhalt bilden, lohnt sich spätestens jetzt eine ernsthafte Prüfung des Wechsels zu R2.
- Bei Diensten mit mehr als 10 TB Download öffnet sich beim Egress eine Lücke von mehreren Zehntausend Dollar im Jahr. Hier lohnt es sich, anhand der oben genannten Migrations-Checkliste (Einschränkungen der S3-kompatiblen API, Super Slurper, Presigned URLs, Workers-Bindings) einen konkreten Kostenvoranschlag zu erstellen.
Zahlen lügen nicht. Je größer der Traffic wird, desto mehr entscheidet ein einziger Satz – “Egress ist kostenlos” – über die gesamte Rechnung.