effidevFlutter · Cloudflare-Edge · Cloud-Kostenoptimierung

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

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.

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:

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:

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

  1. 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.
  2. 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, immutable als Metadaten setzen. Solange sich der Dateiname nicht ändert, ist dann dauerhaftes Caching möglich.
  3. Anzeigegröße und angeforderte Größe in Flutter angleichen: Mit cacheWidth/cacheHeight bei Image.network lassen 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.
  4. 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:

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.

Zahlen lügen nicht. Je größer der Traffic wird, desto mehr entscheidet ein einziger Satz – “Egress ist kostenlos” – über die gesamte Rechnung.