Warum ein einziger Firestore-Listener die Flutter-App-Rechnung sprengt: Die Falle bei $0,60 pro 1 Million Lesevorgängen

Wer schon einmal beobachtet hat, wie sich der Nutzungsgraph in der Firebase-Konsole von einem Tag auf den anderen treppenartig nach oben schiebt, kennt die Ursache meist schon – und sie liegt fast nie in der Businesslogik, sondern im Lebenszyklus der Widgets. Kein neues Feature, kein Nutzeransturm, und trotzdem verzehnfacht sich die Zahl der Firestore-Lesevorgänge über Nacht – dieses Szenario tritt bei Flutter-Apps auffällig häufig auf. Der Grund ist simpel: StreamBuilder und .snapshots() sind so bequem zu schreiben, dass sie den Lebenszyklus des Listeners unsichtbar machen. Bei jedem Rebuild eines Widgets verbindet sich der Listener still und leise neu – und liest dabei Dokumente, die längst geladen waren, komplett von vorn.
Dieser Beitrag ist keine vage Warnung nach dem Motto “Firestore ist teuer”, sondern zeigt anhand exakter Preise, wie viel ein einziges verbreitetes Antipattern tatsächlich zur Monatsrechnung hinzufügt. Es geht um die Preistabelle, drei wiederkehrende Antipatterns in Flutter, die Kostenkurve bei 5.000 bzw. 100.000 DAU, eine Formel zur Rückrechnung des Schwellenwerts fürs Gratis-Kontingent sowie um praxistaugliche Gegenmaßnahmen und alternative Architekturen.
Kernaussagen
- Die Firestore-Blaze-Preise (Region nam5/eur3, siehe offizielle Doku) liegen bei $0,06 pro 100.000 Lesevorgängen, $0,18 pro 100.000 Schreibvorgängen und $0,02 pro 100.000 Löschvorgängen. Bei 1 Million Lesevorgängen sind das $0,60 – für sich genommen klein, aber wenn sich Listener-Reconnects überlagern, multipliziert sich dieser Preis erschreckend schnell.
- Das tägliche Gratis-Kontingent umfasst 50.000 Lesevorgänge, 20.000 Schreibvorgänge, 20.000 Löschvorgänge und 1 GiB Speicher – und gilt nur für die eine Standarddatenbank pro Projekt. Es teilen sich also nicht nur eine App, sondern der gesamte Traffic des Projekts dieses Kontingent.
- Echtzeit-Listener berechnen beim ersten Verbindungsaufbau jedes Dokument der Ergebnismenge einmal als Lesevorgang, danach nur noch geänderte Dokumente. Das Problem: Ist die Offline-Persistenz deaktiviert, wird ein unterbrochener und neu verbundener Listener wie eine komplett neue Query behandelt und liest alle Dokumente erneut. In Flutter genügt dafür oft schon ein einziger Widget-Rebuild.
- Die drei häufigsten Ursachen dafür, dass Flutter+Firestore-Kombinationen die Rechnung explodieren lassen, sind: das N+1-Muster, bei dem in Listenansichten für jedes Element ein separates Dokument abgefragt wird; Listener-Reconnects, bei denen bei jedem Widget-Rebuild ein neuer Stream entsteht; und das Polling-Muster, bei dem statt eines Echtzeit-Listeners wiederholt
.get()aufgerufen wird. - Die Gegenmaßnahmen: Listener-Instanzen cachen, cursor-basierte Pagination einsetzen, N+1 durch Denormalisierung eliminieren und lesenshäufige, selten wechselnde Daten in ein deutlich günstigeres Backend wie Cloudflare D1/KV auslagern – eine Hybridarchitektur.
Zuerst die exakten Firestore-Blaze-Preise festhalten
Statt mit einem groben Bauchgefühl zu starten, beginnen wir mit exakten Zahlen. Die folgende Tabelle stammt direkt von der offiziellen Firebase-/Google-Cloud-Preisseite (Standard-Multiregionen wie nam5 oder eur3).
| Posten | Tägliches Gratis-Kontingent | Preis bei Überschreitung |
|---|---|---|
| Dokument-Lesevorgänge (Document Reads) | 50.000/Tag | $0,06 / 100.000 (also $0,60 pro 1 Million) |
| Dokument-Schreibvorgänge (Document Writes) | 20.000/Tag | $0,18 / 100.000 |
| Dokument-Löschvorgänge (Document Deletes) | 20.000/Tag | $0,02 / 100.000 |
| Gespeicherte Daten (Stored Data) | 1 GiB | Monatlich pro GiB (regional unterschiedlich) |
| Netzwerk-Egress | 10 GiB/Monat | Je nach Region und Ziel unterschiedlich |
An dieser Stelle lohnt sich der Blick auf drei Detailregeln, die in der Praxis oft übersehen werden.
- Eine Query wird auch dann mit mindestens einem Lesevorgang berechnet, wenn das Ergebnis leer ist. Die Annahme “eine Abfrage ohne Treffer ist kostenlos” ist schlicht falsch.
- Komplexe Queries werden zusätzlich pro 1.000 gescannte Indexeinträge mit einem weiteren Lesevorgang berechnet. Das gilt auch für Aggregat-Queries wie
count()– selbst wenn dabei kein einziger Indexeintrag gelesen wird, fällt mindestens ein Lesevorgang an. - Das Gratis-Kontingent gilt nur für die Standarddatenbank (default) eines Projekts. Legt man im selben Projekt eine zusätzliche Datenbank an, wird diese von Anfang an ohne Gratis-Kontingent abgerechnet.
Und es gibt noch eine Regel, die den Kern dieses Beitrags direkt betrifft. Die offizielle Dokumentation beschreibt die Abrechnung von Echtzeit-Listenern so:
“Beim Abonnieren von Query-Ergebnissen wird jedes Mal ein Lesevorgang berechnet, wenn ein Dokument der Ergebnismenge hinzugefügt oder aktualisiert wird. Danach wird ein aktiver Listener nur noch für Dokumente berechnet, bei denen eine Änderung erkannt wird. Wird jedoch bei deaktivierter Offline-Persistenz ein Listener unterbrochen und neu verbunden, erfolgt die Abrechnung so, als würde eine völlig neue Query ausgeführt – mit Lesevorgängen für Dokumente und Indexeinträge.”
Ein .snapshots()-Listener ist also tatsächlich effizient konzipiert: “Einmal verbunden, danach nur noch günstige Deltas.” Das Problem ist nur, dass diese Effizienz voraussetzt, dass der Listener durchgehend verbunden bleibt – und der Widget-Baum von Flutter garantiert das keineswegs standardmäßig.
Drei wiederkehrende Antipatterns in Flutter-Apps
In der Firebase-Community und in echten Produktionsvorfällen lassen sich im Wesentlichen drei Muster immer wieder beobachten. Alle drei “funktionieren” im Code einwandfrei, weshalb sie im Review kaum auffallen – entdeckt werden sie meist erst, wenn die Rechnung ausschlägt.
Antipattern 1: In Listenansichten pro Element ein separates Dokument abfragen (N+1)
Am häufigsten tritt dieses Muster in Ansichten auf, in denen jedes Element einer Liste ein Dokument aus einer anderen Collection referenziert – etwa Chat-Listen, Feeds oder Bestellhistorien.
// Antipattern: Pro Element ein separater get()-Aufruf
ListView.builder(
itemCount: chats.length,
itemBuilder: (context, index) {
final chat = chats[index];
return FutureBuilder<DocumentSnapshot>(
future: FirebaseFirestore.instance
.collection('users')
.doc(chat.otherUserId)
.get(), // Bei 50 Listeneinträgen: 50 zusätzliche Lesevorgänge, bei jedem Scroll/Rebuild erneut
builder: (context, userSnap) {
final name = userSnap.data?.get('displayName') ?? '...';
return ListTile(title: Text(name));
},
);
},
)
Die Listen-Query selbst kostet nur einen Lesevorgang (bzw. so viele wie die Seitengröße bei Pagination), doch für jedes Element wird zusätzlich das Profil des Chatpartners abgefragt – N zusätzliche Lesevorgänge, bei jedem Bildaufbau erneut. Wird durch Scrollen ein Widget neu aufgebaut oder die Ansicht erneut besucht, ohne dass gecacht wird, vervielfacht sich die tatsächliche Zahl der Lesevorgänge im Nu auf ein Mehrfaches von N.
Es gibt zwei Lösungsansätze.
- Batch-Abfragen reduzieren die Anzahl der Roundtrips. Firestores
whereInunterstützt maximal 30 Werte pro Aufruf, daher die IDs in 30er-Blöcke chunken und die Ergebnisse zwingend in einerMapcachen, statt sie erneut abzufragen.
final userIds = chats.map((c) => c.otherUserId).toSet().toList();
final chunks = [
for (var i = 0; i < userIds.length; i += 30)
userIds.sublist(i, (i + 30).clamp(0, userIds.length)),
];
final userDocs = await Future.wait(chunks.map((chunk) =>
FirebaseFirestore.instance
.collection('users')
.where(FieldPath.documentId, whereIn: chunk)
.get(),
));
- Grundsätzlicher: Denormalisierung eliminiert die Abfrage komplett. Häufig benötigte Felder wie
otherUserNameoderotherUserAvatarUrlwerden direkt im Dokumentchats/{chatId}mitgespeichert, und nur bei einer Profiländerung aktualisiert ein Cloud-Functions-Trigger die betroffenen Dokumente. Da Profiländerungen selten, Listenabfragen aber häufig sind, ist das der klassische NoSQL-Tradeoff: ein paar zusätzliche Schreibvorgänge gegen die vollständige Eliminierung von N Lesevorgängen.
Antipattern 2: Der StreamBuilder-Listener verbindet sich bei jedem Widget-Rebuild neu
Das ist das am schwersten zu findende und zugleich teuerste Muster. Steht der .snapshots()-Aufruf inline in der build()-Methode, entsteht bei jedem Aufruf von build() eine neue Stream-Instanz, während der vorherige Listener verworfen wird. Gemäß der zuvor zitierten offiziellen Regel wird bei einem neu verbundenen Listener die gesamte Ergebnismenge erneut als Lesevorgang berechnet.
// Antipattern: Bei jedem build() ein neuer Stream → Listener verbindet sich jedes Mal neu
class ChatRoomScreen extends StatelessWidget {
final String roomId;
const ChatRoomScreen({required this.roomId});
@override
Widget build(BuildContext context) {
return StreamBuilder<QuerySnapshot>(
stream: FirebaseFirestore.instance
.collection('rooms/$roomId/messages')
.orderBy('timestamp', descending: true)
.limit(50)
.snapshots(), // Sobald der Parent setState aufruft, wird auch dieser Stream neu erzeugt
builder: (context, snapshot) { /* ... */ },
);
}
}
Sobald ein völlig chat-unabhängiger Zustand – Tipp-Indikator, Badge-Zähler, Animation-Controller – das Parent-Widget zum Rebuild zwingt, wird auch dieser StreamBuilder mitsamt neuem Stream erzeugt, und die bereits geladenen letzten 50 Nachrichten werden jedes Mal von Grund auf neu gelesen.
Die Lösung besteht darin, die Stream-Instanz vom Widget-Lebenszyklus zu entkoppeln und nur einmal zu erzeugen.
class ChatRoomScreen extends StatefulWidget {
final String roomId;
const ChatRoomScreen({required this.roomId});
@override
State<ChatRoomScreen> createState() => _ChatRoomScreenState();
}
class _ChatRoomScreenState extends State<ChatRoomScreen> {
late final Stream<QuerySnapshot> _messagesStream;
@override
void initState() {
super.initState();
// initState wird während des gesamten Widget-Lebenszyklus nur einmal aufgerufen.
_messagesStream = FirebaseFirestore.instance
.collection('rooms/${widget.roomId}/messages')
.orderBy('timestamp', descending: true)
.limit(50)
.snapshots();
}
@override
Widget build(BuildContext context) {
return StreamBuilder<QuerySnapshot>(
stream: _messagesStream, // Auch bei Rebuild wird derselbe Stream wiederverwendet
builder: (context, snapshot) { /* ... */ },
);
}
}
Wer Riverpod einsetzt, erreicht mit StreamProvider.family denselben Effekt noch expliziter. Der Provider cacht den Stream pro roomId, und allein durch ref.watch wird bei einem Widget-Rebuild kein neuer Stream erzeugt.
final messagesProvider =
StreamProvider.family<QuerySnapshot, String>((ref, roomId) {
return FirebaseFirestore.instance
.collection('rooms/$roomId/messages')
.orderBy('timestamp', descending: true)
.limit(50)
.snapshots();
});
Ob man autoDispose einsetzt, ist eine Abwägungsfrage. Soll der Listener sofort beim Verlassen des Chatraums getrennt werden, ist autoDispose richtig – wenn aber, etwa beim Tab-Wechsel, häufig ein kurzes Verlassen und sofortiges Zurückkehren zur Ansicht vorkommt, sollte man autoDispose mit keepAlive() kombinieren und eine kurze Karenzzeit einräumen, um zu verhindern, dass jeder Tab-Wechsel erneute Reconnect-Kosten verursacht.
Antipattern 3: get() wird statt eines Echtzeit-Listeners wie Polling wiederholt aufgerufen
Manche Teams kommen zu dem Schluss, dass sie die Echtzeitfähigkeit von .snapshots() gar nicht brauchen, und rufen stattdessen per Timer.periodic alle paar Sekunden .get() auf. Auf den ersten Blick liegt der Gedanke nahe: “Ohne Listener müsste es doch günstiger sein” – tatsächlich ist es genau umgekehrt.
// Antipattern: Polling alle 5 Sekunden – unabhängig davon, ob sich etwas geändert hat, wird jedes Mal alles neu gelesen
Timer.periodic(const Duration(seconds: 5), (_) async {
final snap = await FirebaseFirestore.instance
.collection('rooms/$roomId/messages')
.orderBy('timestamp', descending: true)
.limit(50)
.get();
updateUi(snap.docs);
});
Ein .snapshots()-Listener wird nach dem initialen Verbindungsaufbau nur noch für geänderte Dokumente berechnet, während .get()-Polling bei jedem Aufruf die komplette Ergebnismenge neu liest – unabhängig davon, ob sich überhaupt etwas geändert hat. Bei 12 Abrufen pro Minute (Intervall: 5 Sekunden) und einer Ergebnismenge von 50 Dokumenten entstehen selbst ohne jede Änderung 600 Lesevorgänge pro Minute, also 36.000 pro Stunde. Ist Echtzeitfähigkeit tatsächlich gefragt, ist .snapshots() von vornherein strukturell günstiger. Ist Polling wirklich unumgänglich (etwa bei batchartiger Synchronisation), sollte man das Intervall verlängern, limit minimieren oder die Query über einen updated_at-Cursor so eingrenzen, dass nur geänderte Daten abgefragt werden.
Konkretes Rechenbeispiel: Wie eine Chat-App mit 5.000 DAU durch ein einziges Antipattern teurer wird
Wir rechnen das mit Antipattern 2 (Listener-Reconnects) durch, weil es von den drei Mustern die größte Hebelwirkung hat. Die folgenden Annahmen sind ein vereinfachtes Modell realer Chat-App-Traffic-Muster – keine exakte Benchmark-Messung, sondern ein Beispiel, um ein Gefühl dafür zu bekommen, mit welchem Faktor die Kosten anwachsen.
Annahmen
- 5.000 DAU (Daily Active Users)
- Jeder Nutzer hat im Schnitt eine Chatroom-Session pro Tag; innerhalb der Session kommt es durchschnittlich zu 10 Listener-Reconnects durch Parent-Widget-Rebuilds (verursacht durch chat-unabhängige Zustandsänderungen wie Tipp-Indikator, Lesebestätigung usw.)
- Bei jedem Reconnect werden die letzten 50 Nachrichten (
limit(50)) komplett neu gelesen
Rechnung
Zusätzliche Lesevorgänge pro Nutzer und Tag = 10 Reconnects × 50 Dokumente = 500
5.000 DAU × 500 = 2.500.000 zusätzliche Lesevorgänge pro Tag
Monatlich (30 Tage) = 2.500.000 × 30 = 75.000.000 (75 Millionen) zusätzliche Lesevorgänge
Zusatzkosten = 75.000.000 / 100.000 × $0,06 = 750 × $0,06 = $45/Monat
Was passiert, wenn diese App auf 100.000 DAU wächst? Bei gleichem Antipattern und gleichem Lesevorgangs-Multiplikator pro Nutzer (500/Tag) ergibt sich:
100.000 DAU × 500 = 50.000.000 zusätzliche Lesevorgänge pro Tag
Monatlich = 50.000.000 × 30 = 1.500.000.000 (1,5 Milliarden) zusätzliche Lesevorgänge
Zusatzkosten = 1.500.000.000 / 100.000 × $0,06 = 15.000 × $0,06 = $900/Monat
Wächst die DAU-Zahl um das 20-Fache, wachsen auch die durch dieses Antipattern verursachten Zusatzkosten exakt um das 20-Fache (von $45 auf $900 pro Monat). Da die durch Listener-Reconnects verursachte Verschwendung linear mit der Nutzerzahl skaliert, entsteht die typische Kurve: “Bei wenigen Nutzern fällt es kaum auf, doch sobald die Skalierung zunimmt, wird es plötzlich beängstigend sichtbar.” Hinzu kommt: In realen Apps tritt selten nur ein einziges Antipattern isoliert auf. Treten N+1-Abfragen und Listener-Reconnects gleichzeitig auf, überlagern sich die beiden Verschwendungsquellen multiplikativ und erzeugen eine noch deutlich steilere Rechnung als oben berechnet – denn hier multiplizieren sich die Anzahl der Listenelemente (N), die Anzahl der Listener pro Element (L) und die Reconnect-Häufigkeit (F).
Den Schwellenwert zurückrechnen, ab dem das tägliche Gratis-Kontingent (50.000) aufgebraucht ist
Selbst eine App mit “noch überschaubarer” Skalierung von etwa 5.000 DAU erreicht das Gratis-Kontingent von 50.000 Lesevorgängen schneller, als man denkt. Mit folgender Formel lässt sich der Schwellenwert zurückrechnen.
Anzahl gleichzeitiger Nutzer (C) × durchschnittliche Listener pro Session (L) × durchschnittliche initiale Lesevorgänge pro Listener (R) × Reconnect-Faktor (F)
= Gesamtzahl der Lesevorgänge pro Tag
Maximale Anzahl gleichzeitiger Nutzer, ohne das Gratis-Kontingent zu überschreiten:
C_max = 50.000 / (L × R × F)
Beispiel 1: Ein “gesunder” Zustand ohne Reconnect-Antipattern – Chat-Liste (1 Listener, 20 Ergebnisse) plus aktueller Chatroom (1 Listener, 50 Ergebnisse) ergibt L=2, durchschnittlich R=35, F=1 (keine Reconnects):
C_max = 50.000 / (2 × 35 × 1) = 50.000 / 70 ≈ 714 Nutzer
Bis etwa 714 gleichzeitige Nutzer bleibt man innerhalb des Gratis-Kontingents.
Beispiel 2: Zustand mit dem oben berechneten Reconnect-Antipattern – bei gleichem L=2 und R=35, aber durchschnittlich 5 Reconnects pro Session (F=5):
C_max = 50.000 / (2 × 35 × 5) = 50.000 / 350 ≈ 142 Nutzer
Allein durch Listener-Reconnects sinkt die Zahl gleichzeitiger Nutzer, die noch im Gratis-Kontingent bleibt, von 714 auf 142 – auf ein Fünftel. Genau deshalb bricht die Annahme “unsere App hat nur ein paar Hundert Nutzer, das Gratis-Kontingent reicht locker” in der Praxis oft schon vor Mittag zusammen – wegen eines einzigen Antipatterns. Und man sollte nicht vergessen: Das Gratis-Kontingent gilt für die gesamte Standarddatenbank des Projekts – teilen sich Staging und Produktion dasselbe Projekt, zehren sogar die Hot-Reload-Tests der Entwickler an diesem Kontingent.
Praxistaugliche Muster zur Kostensenkung
Über die bereits behandelten einzelnen Code-Fixes hinaus folgen hier Muster, die sich auf Architekturebene anwenden lassen.
- Client-seitiges Caching explizit aktivieren.
cloud_firestorehat die Offline-Persistenz standardmäßig aktiviert (zumindest auf Mobilplattformen), aber Cache-Größe und -Policy sollten explizit geprüft werden. Für Ansichten ohne Echtzeitbedarf lässt sich mitGetOptions(source: Source.cache)der Serverzugriff komplett umgehen.
final cached = await docRef.get(const GetOptions(source: Source.cache));
- Für Listenansichten ausnahmslos cursor-basierte Pagination einsetzen. Code, der ohne
limit()eine ganze Collection abonniert, treibt mit wachsender Collection auch die initialen Lesekosten immer weiter nach oben.
Query query = FirebaseFirestore.instance
.collection('posts')
.orderBy('createdAt', descending: true)
.limit(20);
if (lastDocument != null) {
query = query.startAfterDocument(lastDocument);
}
final snapshot = await query.get();
- N+1 durch partielle Updates und Denormalisierung an der Wurzel reduzieren. Statt mit
.set()das gesamte Dokument zu überschreiben, mit.update()nur geänderte Felder schreiben, und häufig abgefragte verknüpfte Daten – wie oben beschrieben – direkt im übergeordneten Dokument mitspeichern. - Den Lebenszyklus der Listener der State-Management-Schicht überlassen, nicht dem Widget-Baum. Den Stream nur einmal in
initStateerzeugen oder ihn auf Provider-Ebene von Riverpod/Bloc cachen, um Rebuild und Listener-Reconnect zu entkoppeln. Das ist der wirksamste Hebel, um das folgenreichste der hier behandelten Antipatterns von vornherein zu unterbinden. - Lesenshäufige, selten wechselnde Daten aus Firestore auslagern. Nur Daten, bei denen Echtzeit-Synchronisation wirklich unverzichtbar ist – Chat-Nachrichten, Presence, kollaborative Editier-Zustände – bleiben in Firestore. Statische Konfigurationswerte, Produktkataloge oder Ranglisten, die zwar häufig gelesen werden, aber nicht in Echtzeit vorliegen müssen, sollten in eine deutlich günstigere Hybridarchitektur wandern, etwa Cloudflare Workers + D1 (oder KV). Die Flutter-App fragt diese Daten dann nicht über das Firestore-SDK ab, sondern per REST-Aufruf gegen den Worker-Endpunkt.
Preisvergleich zu Firestore-Alternativen: Cloudflare D1, Supabase Postgres
Zum Schluss noch die Antwort auf die Frage: “Wenn die Lesekosten so hoch sind, wäre dann nicht von Anfang an ein anderes Backend die bessere Wahl?” Dazu ein Blick auf die offiziellen Preise zweier Alternativen.
| Posten | Firestore (Blaze) | Cloudflare D1 | Supabase Postgres |
|---|---|---|---|
| Gratis-Lesekontingent | 50.000/Tag | 5 Millionen Zeilen/Tag (Workers-Free-Plan) | API-Anfragen selbst unbegrenzt (nur Kapazitäts-/Performance-Limits) |
| Gratis-Speicherplatz | 1 GiB | 5 GB (gesamtes Konto) | 500 MB |
| Preis pro Überschreitung (Lesen) | $0,06 / 100.000 | Im Workers-Paid-Plan ($5/Monat) 25 Milliarden Zeilen/Monat inklusive, danach $0,001 pro 1 Million Zeilen | Erweiterung von Speicher/Verbindungen per Plan-Upgrade (keine Abrechnung pro Anfrage) |
| Preis pro Überschreitung (Schreiben) | $0,18 / 100.000 | Im Workers-Paid-Plan 50 Millionen Zeilen/Monat inklusive, danach $1,00 pro 1 Million Zeilen | Identisch |
| Nativer Echtzeit-Listener | Unterstützt (WebSocket-basierter Stream – zugleich Ursache der in diesem Beitrag behandelten Kostenstruktur) | Standardmäßig nicht unterstützt (lässt sich mit Durable Objects selbst implementieren) | Bietet Realtime-Funktion (basiert auf Postgres Logical Replication, mit eigenen Limits) |
Rein zahlenmäßig wirkt der D1-Überschreitungspreis gegenüber Firestore geradezu unschlagbar günstig ($0,001 pro 1 Million Zeilen vs. $0,60 pro 1 Million Dokumente) – doch dabei muss man festhalten, dass es sich nicht um vergleichbare Produkte handelt. D1 rechnet in SQL-Zeilen (Rows) ab und setzt den $5-Workers-Paid-Plan voraus; außerdem ersetzt es nicht ohne Weiteres, was Firestore standardmäßig mitbringt: Echtzeit-Listener, Offline-Synchronisation und automatische Skalierung. Auch Supabase bietet zwar eine Realtime-Funktion, unterliegt aber eigenen Limits bei gleichzeitigen Verbindungen und Nachrichtenzahl – ein 1:1-Ersatz für Firestores .snapshots() ist das nicht.
Die praktisch vernünftige Schlussfolgerung lautet: Nur dort, wo echte Echtzeit-Synchronisation gebraucht wird – etwa bei Chat-Nachrichten oder kollaborativem Editieren – bei Firestore (oder einer vergleichbaren Echtzeit-Datenbank) bleiben, und alle übrigen Daten, die zwar häufig gelesen, aber nicht in Echtzeit benötigt werden, in ein günstigeres Backend wie D1/Supabase auslagern. Allein diese Trennung kann einen Großteil der zuvor berechneten Verschwendung von $45 bis $900 pro Monat von vornherein verhindern.
Abschließende Checkliste
Um die Rechnung einer Flutter+Firestore-Kombination im Griff zu behalten, empfiehlt sich folgende Reihenfolge zur Überprüfung.
- Steht der
.snapshots()-Aufruf nicht inline in derbuild()-Methode (sondern wurde nachinitStateoder auf Provider-Ebene verlagert)? - Wird in Listenansichten nicht für jedes Element ein separater
get()-Aufruf gemacht (sondern durch Batch-Abfragen oder Denormalisierung ersetzt)? - Gibt es noch
Timer.periodic+.get()-Polling (lässt es sich durch einen Echtzeit-Listener ersetzen)? - Sind bei allen Listen-/Collection-Abonnements
limit()und Pagination-Cursor gesetzt? - Teilen sich Staging und Produktion nicht dasselbe Firebase-Projekt (= dasselbe Gratis-Kontingent)?
- Gibt es Spielraum, lesenshäufige Daten ohne Echtzeitbedarf aus Firestore auszulagern?
Schon diese sechs Punkte verhindern die meisten Fälle, in denen “die Rechnung plötzlich explodiert, obwohl sich am Code nichts geändert hat”.