effidevFlutter · Cloudflare edge · Cloud cost optimization

Why a Single Firestore Listener Can Blow Up Your Flutter App's Bill: The $0.60-per-Million-Reads Trap

Why a Single Firestore Listener Can Blow Up Your Flutter App's Bill: The $0.60-per-Million-Reads Trap

If you’ve ever watched the usage graph in the Firebase console suddenly jump up in a staircase pattern one day, the cause is almost always widget lifecycle, not application logic. Firestore read counts jumping 10x overnight without a single new feature shipping or any surge in users is a failure mode that shows up disproportionately often in Flutter apps. The reason is simple: StreamBuilder and .snapshots() are easy to write precisely because they hide the listener’s lifecycle from you, and every time the widget rebuilds, the listener quietly reconnects and re-reads documents it had already read from the beginning.

This post isn’t a vague warning that “Firestore is expensive.” It’s a walkthrough with real numbers showing exactly how much a single common anti-pattern adds to a monthly bill, calculated against actual pricing. It covers the pricing table, three recurring anti-patterns in Flutter apps, the cost curve at 5,000 and 100,000 DAU scale, how to work backward to the threshold where the free daily quota runs out, and practical fixes and alternative architectures.

Key Takeaways

Let’s Start With Firestore Blaze’s Exact Pricing

Rather than working from a vague impression, let’s start with exact numbers. The table below comes straight from the official Firebase/Google Cloud pricing page (based on the default multi-region locations, nam5 and eur3).

Item Free daily quota Overage price
Document Reads 50,000/day $0.06 / 100K ($0.60 per million)
Document Writes 20,000/day $0.18 / 100K
Document Deletes 20,000/day $0.02 / 100K
Stored Data 1 GiB Billed monthly per GiB (varies by region)
Network Egress 10 GiB/month Varies by region and destination

There are three fine-print rules practitioners commonly miss.

And there’s one more rule that goes straight to the heart of this post. Here’s how the official docs describe billing for real-time listeners:

“When you listen to the results of a query, you are charged for a read each time a document in the result set is added or updated. After the initial connection, subsequent charges to an active listener are only for documents that changed. However, if the listener is disconnected for more than 30 minutes (for example, if the user closes their computer) or offline persistence is disabled and the listener reconnects, you’re charged for all the reads to re-execute the query, as though it were new.

In other words, a .snapshots() listener really is an efficient structure — connect once, and everything after that comes cheap, billed only on changes. The problem is that this efficiency only holds as long as that listener actually stays connected, and Flutter’s widget tree offers no guarantee, by default, that it will.

Three Anti-Patterns That Keep Showing Up in Flutter Apps

Across the Firebase community and real production incidents, three patterns show up over and over. All three “work” from a functional standpoint, so they rarely get caught in code review — they only surface once the bill spikes.

Anti-Pattern 1: Fetching a Separate Document Per List Item (N+1)

This is most common on screens where each item in a list references a document in a different collection — chat lists, feeds, order histories.

// Anti-pattern: a separate get() call for every single item
ListView.builder(
  itemCount: chats.length,
  itemBuilder: (context, index) {
    final chat = chats[index];
    return FutureBuilder<DocumentSnapshot>(
      future: FirebaseFirestore.instance
          .collection('users')
          .doc(chat.otherUserId)
          .get(), // 50 items in the list = 50 extra reads, repeated on every scroll and rebuild
      builder: (context, userSnap) {
        final name = userSnap.data?.get('displayName') ?? '...';
        return ListTile(title: Text(name));
      },
    );
  },
)

The list query itself is cheap — one read (or however many the page size is, with pagination) — but fetching the other user’s profile for every item generates N extra reads on every single render. If this lookup runs uncached every time the widget rebuilds during scrolling or the screen is revisited, the effective read count balloons to several multiples of N in no time.

There are two fixes.

  1. Batch the lookups to cut down round trips. Firestore’s whereIn supports at most 30 values per call, so chunk the IDs into groups of 30 and always cache the results in a Map for reuse.
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. More fundamentally, eliminate the lookup entirely with denormalization. Store frequently-used fields like otherUserName and otherUserAvatarUrl directly inside the chats/{chatId} document, and only update the related documents via a Cloud Functions trigger when the profile actually changes. Profile changes are rare and list views are frequent, so this is the classic NoSQL trade-off: add a handful of writes to eliminate N reads entirely.

Anti-Pattern 2: StreamBuilder Listeners Reconnecting on Every Widget Rebuild

This is the hardest to spot and the most expensive of the three. If you put a .snapshots() call inline inside build(), every time build runs, a brand-new stream instance is created and the previous listener is discarded. Per the official rule quoted above, a freshly-attached listener is billed for the entire result set all over again.

// Anti-pattern: a new stream on every build() → listener reconnects every time
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(), // recreated any time the parent calls setState
      builder: (context, snapshot) { /* ... */ },
    );
  }
}

Whenever state changes unrelated to the chat screen — a typing indicator, a badge count, an animation controller — rebuild the parent widget, this StreamBuilder gets recreated along with it, and the 50 most recent messages it had already loaded get re-read from scratch, every single time.

The fix is to decouple the stream instance from the widget lifecycle and create it exactly once.

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 only runs once for the lifetime of the widget.
    _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, // same stream reused across rebuilds
      builder: (context, snapshot) { /* ... */ },
    );
  }
}

If you’re using Riverpod, StreamProvider.family gets you the same result more explicitly. The provider caches the stream per roomId, and ref.watch alone won’t recreate the stream just because the widget rebuilds.

final messagesProvider =
    StreamProvider.family<QuerySnapshot, String>((ref, roomId) {
  return FirebaseFirestore.instance
      .collection('rooms/$roomId/messages')
      .orderBy('timestamp', descending: true)
      .limit(50)
      .snapshots();
});

Whether to add autoDispose is a trade-off. If you want the listener torn down the moment the user leaves the chat room, autoDispose is the right call. But for patterns where users frequently leave a screen briefly and come right back — switching tabs, for instance — you’ll want autoDispose combined with keepAlive() to add a short grace period, so switching tabs back and forth doesn’t rack up reconnect costs every time.

Anti-Pattern 3: Polling with get() Instead of a Real-Time Listener

Some teams decide they don’t need the real-time behavior of .snapshots() and instead use Timer.periodic to call .get() every few seconds. It’s tempting to assume this is cheaper because “we’re not using a listener,” but the opposite is true.

// Anti-pattern: polling every 5 seconds — re-fetches the entire result set every time, regardless of whether anything changed
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);
});

A .snapshots() listener, after its initial connection, is billed only for documents that changed. .get() polling, by contrast, re-reads the entire result set every time it’s called, regardless of whether anything actually changed. Poll 12 times a minute (every 5 seconds) with a result set of 50, and you’re generating 600 reads a minute — 36,000 reads an hour — even if nothing changed at all. If a screen genuinely needs real-time behavior, using .snapshots() from the start is structurally cheaper. And for the cases where polling really is the right tool (batch-style sync jobs, for example), widen the interval, keep limit as small as possible, or narrow the query to fetch only changed records using an updated_at cursor.

A Real Calculation: How One Anti-Pattern Inflates the Bill for a 5,000-DAU Chat App

Let’s run the numbers on Anti-Pattern 2 (listener reconnects), since it’s the one with the biggest blast radius of the three. The assumptions below are a simplified model of a real chat app’s traffic pattern — this isn’t a precise benchmark, it’s an example meant to give you a feel for the multiplier the cost grows by.

Assumptions

Calculation

Extra reads per user per day = 10 reconnects × 50 documents = 500 reads
5,000 DAU × 500 reads = 2,500,000 extra reads per day

Monthly extra reads (30 days) = 2,500,000 × 30 = 75,000,000 (75 million)

Extra cost = 75,000,000 / 100,000 × $0.06 = 750 × $0.06 = $45/month

What happens if this app grows to 100,000 DAU? Assuming the same anti-pattern and the same per-user read multiplier (500 reads/day):

100,000 DAU × 500 reads = 50,000,000 extra reads per day
Monthly extra reads = 50,000,000 × 30 = 1,500,000,000 (1.5 billion)
Extra cost = 1,500,000,000 / 100,000 × $0.06 = 15,000 × $0.06 = $900/month

A 20x increase in DAU means exactly a 20x increase in the extra cost from this anti-pattern too — from $45/month to $900/month. Because the waste from listener reconnects scales linearly with user count, it follows the classic curve where “it’s not noticeable at low user counts, but becomes alarming the moment you scale.” And in real apps, it’s rare for only a single anti-pattern to be present. When N+1 lookups and listener reconnects coexist, the two sources of waste compound multiplicatively, producing a far steeper bill than the calculation above — because the number of items in the list (N), the number of listeners per item (L), and the reconnect frequency (F) all multiply together.

Working Backward to Find When the Free Daily Quota (50,000 Reads) Runs Out

Even for an app at “not-huge-yet” scale — say, 5,000 DAU — the 50,000-read free quota runs out much faster than you’d think. You can work this out backward with the following formula.

Concurrent users (C) × avg listeners per session (L) × avg initial documents read per listener (R) × reconnect multiplier (F)
= total reads per day

Max concurrent users within the free quota: C_max = 50,000 / (L × R × F)

Example 1: a “healthy” state with no reconnect anti-pattern — a chat list screen (1 listener, 20 results) plus the current chat room (1 listener, 50 results) gives L=2, average R=35, F=1 (no reconnects):

C_max = 50,000 / (2 × 35 × 1) = 50,000 / 70 ≈ 714 users

Up to roughly 714 concurrent users, this stays within the free quota.

Example 2: the same app with the reconnect anti-pattern from the calculation above mixed in — same L=2 and R=35, but with an average of 5 reconnects per session (F=5):

C_max = 50,000 / (2 × 35 × 5) = 50,000 / 350 ≈ 142 users

A single listener-reconnect anti-pattern cuts the number of concurrent users the free quota can sustain from 714 down to 142 — to one-fifth. This is exactly why the assumption “we only have a few hundred users, so the free tier is plenty” falls apart before noon, purely because of one anti-pattern. It’s also worth remembering that the free quota is shared across a project’s entire default database — if staging and production share the same Firebase project, even a developer’s hot-reload testing chips away at that same quota.

Practical Patterns for Cutting Costs

Beyond the individual code fixes covered above, here are patterns to apply at the architecture level.

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();

Comparing Firestore Alternatives: Cloudflare D1, Supabase Postgres

Finally, to answer the question “if reads are this expensive, wouldn’t a different backend make more sense to begin with,” here’s a look at the official pricing of two alternatives.

Item Firestore (Blaze) Cloudflare D1 Supabase Postgres
Free read quota 50,000/day 5 million rows/day (Workers Free plan) API requests themselves are unlimited (only capacity/performance limits apply)
Free storage 1 GiB 5GB (account-wide) 500MB
Paid read overage $0.06 / 100K Included up to 25 billion rows/month on the Workers Paid ($5/month) plan; $0.001 per million rows beyond that Storage and connection limits scale with plan upgrades (no per-request billing)
Paid write overage $0.18 / 100K Included up to 50 million rows/month on Workers Paid; $1.00 per million rows beyond that Same as above
Native real-time listener Supported (WebSocket-based streaming — also the root cause of the cost structure covered in this post) Not supported by default (can be built manually with Durable Objects) Realtime feature available (built on Postgres logical replication, with its own separate limits)

On the numbers alone, D1’s paid overage rate looks dramatically cheaper than Firestore’s ($0.001 per million rows vs. $0.60 per million reads), but it’s worth flagging that this isn’t an apples-to-apples comparison. D1 bills per SQL row, requires the $5/month Workers Paid plan as a prerequisite, and doesn’t replace what Firestore gives you out of the box — real-time listeners, offline sync, and automatic scaling. Supabase also offers a Realtime feature, but it comes with its own separate limits on concurrent connections and messages, so it’s not a straight 1:1 swap for Firestore’s .snapshots() either.

The practically sound conclusion is this: keep the parts that genuinely need real-time sync — chat messages, collaborative editing — on Firestore (or an equivalent real-time database), and split off the rest of the data, which is read often but doesn’t need to be real time, onto a cheaper backend like D1 or Supabase. That split alone can prevent most of the “$45 to $900 a month in waste from anti-patterns” calculated above from ever happening in the first place.

Final Checklist

To keep a Flutter+Firestore app’s bill under control, check the following items in order.

Checking just these six items alone will prevent most of the “the bill suddenly spiked and we didn’t even change any code” incidents before they happen.