effidevFlutter · Cloudflare-Edge · Cloud-Kostenoptimierung

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

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

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.

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.

  1. Batch-Abfragen reduzieren die Anzahl der Roundtrips. Firestores whereIn unterstützt maximal 30 Werte pro Aufruf, daher die IDs in 30er-Blöcke chunken und die Ergebnisse zwingend in einer Map cachen, 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(),
));
  1. Grundsätzlicher: Denormalisierung eliminiert die Abfrage komplett. Häufig benötigte Felder wie otherUserName oder otherUserAvatarUrl werden direkt im Dokument chats/{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

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.

final cached = await docRef.get(const GetOptions(source: Source.cache));
Query query = FirebaseFirestore.instance
    .collection('posts')
    .orderBy('createdAt', descending: true)
    .limit(20);

if (lastDocument != null) {
  query = query.startAfterDocument(lastDocument);
}
final snapshot = await query.get();

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.

Schon diese sechs Punkte verhindern die meisten Fälle, in denen “die Rechnung plötzlich explodiert, obwohl sich am Code nichts geändert hat”.