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
- Firestore Blaze pricing (for the nam5/eur3 region — verify against the official docs) is $0.06 per 100,000 reads, $0.18 per 100,000 writes, and $0.02 per 100,000 deletes. That’s $0.60 per million reads — a number that looks small in isolation, but multiplies terrifyingly fast once listener reconnects start stacking up.
- The free daily quota is 50,000 reads, 20,000 writes, 20,000 deletes, and 1 GiB of storage, and it applies only to a single default database per project. That quota is shared across a project’s entire traffic, not per app.
- A real-time listener is billed once per document in the result set on its initial connection, and only for changed documents afterward. The catch is that if offline persistence is disabled, every time a listener disconnects and reconnects, it’s treated as an entirely new query and re-reads every document. In Flutter, this reconnect can be triggered as easily as a single widget rebuild.
- The three most common causes of runaway Firestore bills in Flutter+Firestore apps are the N+1 pattern, where a list screen fetches a separate document per item; listener reconnects, where a widget rebuild spawns a brand-new stream; and the polling pattern, where
.get()is called repeatedly instead of using a real-time listener. - The fix is a hybrid architecture: cache listener instances, use cursor-based pagination, eliminate N+1 through denormalization, and move read-heavy, low-volatility data to a much cheaper backend like Cloudflare D1/KV.
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.
- A query is billed for a minimum of one read even if it returns zero results. The assumption that “querying for data that doesn’t exist is free” is wrong.
- Complex queries are billed an additional read for every 1,000 index entries scanned. Aggregation queries like
count()follow the same rule, and are billed for a minimum of one read even when no index entries are scanned at all. - The free quota applies only to a project’s single default database. If you create an additional database in the same project, that database is billed from the very first request, with no free quota of its own.
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.
- Batch the lookups to cut down round trips. Firestore’s
whereInsupports at most 30 values per call, so chunk the IDs into groups of 30 and always cache the results in aMapfor 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(),
));
- More fundamentally, eliminate the lookup entirely with denormalization. Store frequently-used fields like
otherUserNameandotherUserAvatarUrldirectly inside thechats/{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
- 5,000 DAU (daily active users)
- Each user has one chat room session per day on average, and during that session the parent widget’s rebuilds trigger an average of 10 listener reconnects (caused by state changes unrelated to the chat itself, like typing indicators and read receipts)
- Each reconnect re-reads the 50 most recent messages (
limit(50)) from scratch
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.
- Explicitly enable client-side caching.
cloud_firestorehas offline persistence enabled by default on mobile, but you should still explicitly check the cache size and policy, and skip the round trip to the server entirely for screens that don’t need real-time data, usingGetOptions(source: Source.cache).
final cached = await docRef.get(const GetOptions(source: Source.cache));
- Apply cursor-based pagination to every single list screen, no exceptions. Code that subscribes to an entire collection without
limit()grows its initial read cost right alongside the collection.
Query query = FirebaseFirestore.instance
.collection('posts')
.orderBy('createdAt', descending: true)
.limit(20);
if (lastDocument != null) {
query = query.startAfterDocument(lastDocument);
}
final snapshot = await query.get();
- Use partial updates and denormalization to fundamentally cut down N+1. Write only the fields that changed with
.update()instead of overwriting the whole document with.set(), and store frequently-queried related data directly in the parent document as covered above. - Make the listener lifecycle owned by the state-management layer, not the widget tree. Create the stream exactly once in
initState, or cache it at the provider level with Riverpod/Bloc, so widget rebuilds and listener reconnects are decoupled from each other. This is the single change that shuts off the anti-pattern with the biggest impact of everything covered in this post. - Move read-heavy, low-volatility data out of Firestore entirely. Keep only the data that genuinely needs real-time sync in Firestore — chat messages, presence, collaborative editing state — and consider a hybrid architecture where data that’s read often but doesn’t need to be real time (static config values, product catalogs, leaderboards) moves to a much cheaper backend like Cloudflare Workers + D1 (or KV). The Flutter app can hit that Worker’s endpoint with a plain REST call instead of going through the Firestore SDK.
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.
- Is any
.snapshots()call sitting inline inside abuild()method (has it been moved toinitStateor the provider level)? - Is a list screen calling a separate
get()per item (has it been replaced with batching or denormalization)? - Is there still a
Timer.periodic+.get()polling loop in the codebase (could it be replaced with a real-time listener)? - Does every list/collection subscription have
limit()and a pagination cursor applied? - Are staging and production sharing the same Firebase project (and therefore the same free quota)?
- Is there room to move read-heavy data that doesn’t need real-time behavior out of Firestore?
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.