In a Flutter app using cloud_firestore, why is calling snapshots() inside build() costly, and how do you keep Firestore listeners cheap?
answer
- one stream per call
- native listener starts on first listen
- torn down when the last subscriber cancels
- new stream object, new initial result
- includeMetadataChanges doubles local-write events
basics
~20 sEach snapshots() call creates a new stream whose first listen starts a native listener that re-runs the query. Calling it in build() restarts that listener on every rebuild; create the stream once, cancel manual subscriptions, and keep queries narrow.
solid answer
~50 sIn the pinned `cloud_firestore`, every `snapshots()` call builds a new broadcast `StreamController`: its first `listen` starts a native snapshot listener that runs the query and delivers the full initial result, and cancelling the last subscription tears the listener down. Calling `snapshots()` in `build()` therefore hands the widget layer a new stream on each rebuild, so it cancels the old subscription and subscribes to the new one — restarting the listener, re-delivering every document, flashing the loading state and, under Firestore's billing model, costing more reads. The fixes: create the stream once in `initState`, a repository or a provider; cancel manual subscriptions in `dispose`; narrow queries with `where` and `limit`; leave `includeMetadataChanges` off unless the UI reads the metadata; and use `get()` for data that need not stay live. Several widgets can share one broadcast stream and so one native listener.
code
dart · 39 linesimport 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/widgets.dart';
class ItineraryWatcher extends StatefulWidget {
const ItineraryWatcher({super.key, required this.tripId});
final String tripId;
@override
State<ItineraryWatcher> createState() => _ItineraryWatcherState();
}
class _ItineraryWatcherState extends State<ItineraryWatcher> {
StreamSubscription<QuerySnapshot<Map<String, dynamic>>>? _sub;
int _count = 0;
@override
void initState() {
super.initState();
// Created once, not in build().
_sub = FirebaseFirestore.instance
.collection('trips')
.doc(widget.tripId)
.collection('itinerary')
.limit(50)
.snapshots()
.listen((snapshot) => setState(() => _count = snapshot.size));
}
@override
void dispose() {
_sub?.cancel(); // stops the native listener
super.dispose();
}
@override
Widget build(BuildContext context) => Text('$_count items');
}go deeper
Remember never to create a Firestore stream inside build(), and to cancel any subscription you open yourself.
Explain the lifecycle: stream per call, native listener on first listen, teardown on last cancel, and why a new stream object restarts it.
Find listener leaks and rebuild restarts in a real screen, narrow queries, share one stream across consumers, and justify each metadata listener.
Set data-layer rules that make listener counts predictable per screen and user, balancing real-time freshness against read volume and device work.
## What a `snapshots()` call really creates In the pinned `cloud_firestore` method-channel implementation, each call to `snapshots()` on a query builds a **new broadcast `StreamController`**. Its `onListen` callback asks the native Firestore SDK to start a snapshot listener for that query; its `onCancel` callback cancels the subscription to that native listener when the last Dart subscriber goes away. In other words: - calling `snapshots()` costs nothing until someone listens; - the first `listen` starts a **native listener**, which runs the query and delivers the full initial result; - cancelling the last subscription tears it down. That mapping — one Dart stream, one native listener — is why listener placement matters. ## The rebuild trap The classic defect is calling `snapshots()` inside a widget's `build` method in a group-trip planner: 1. `build` runs and calls `tripsRef.doc(id).collection('itinerary').snapshots()`, creating stream A. 2. The widget layer subscribes to A, starting a native listener. 3. Anything triggers a rebuild — a theme change, a parent `setState`, a keyboard appearing. 4. `build` creates stream B. It is a different object, so the old subscription to A is cancelled and B is subscribed, starting a **new** native listener. 5. The new listener delivers the full initial result again, and the screen briefly shows its loading state. Each rebuild re-runs the query and re-delivers every document. On a large itinerary with photos that is visible flicker, extra work on the UI isolate for conversion, and — under Firestore's billing model — more document reads. The fix is to **create the stream once**: in `initState`, a field of a repository, or a state-management provider, and hand the same object to the widget on every build. ## Listeners that never end The opposite leak is a manual subscription that is never cancelled: - `stream.listen(...)` in `initState` without `subscription.cancel()` in `dispose` keeps the native listener alive after the screen is gone. - A service that listens to every trip the user ever opened accumulates listeners for the life of the process. - Listeners on broad queries (a whole collection) push every change to every document, even ones no screen shows. ## Making each event cheaper | Lever | Effect | |---|---| | Narrow the query (`where`, `limit`) | Fewer documents in the initial result and fewer change events | | Listen to a document instead of a collection | One document's changes only | | Leave `includeMetadataChanges` at its default `false` | No extra events when only `hasPendingWrites` or `isFromCache` flips | | Use `docChanges` to update incrementally | Avoids rebuilding a whole list for one modified item | | Use `get()` for data that does not need to stay live | No open listener at all | `includeMetadataChanges: true` is sometimes right — to show "saved" once `hasPendingWrites` turns `false` — but it roughly doubles the events for every local write, so enable it only where the UI uses the metadata. ## Sharing one listener Because the returned stream is a broadcast stream, several widgets can subscribe to **the same stream object** and share one native listener. The listener stops only when all of them have cancelled. That is the pattern a repository or provider implements: one stream per query, many consumers. ## Diagnosing a listener problem 1. **Symptom: the list flashes its loading state on unrelated rebuilds.** Look for `snapshots()` inside `build`, or a stream getter that creates a new stream on every access. 2. **Symptom: updates keep arriving for a screen the user left.** Look for a `listen` without a matching `cancel`. 3. **Symptom: every keystroke elsewhere in the app causes Firestore traffic.** Look for a parent `setState` rebuilding the widget that creates the stream. 4. **Symptom: far more events than document changes.** Check for `includeMetadataChanges: true` and broad collection-wide queries. ## A checklist for review - Is every `snapshots()` call outside `build`? - Does every manual `listen` have a matching `cancel` in `dispose`? - Could this screen use `get()` instead? - Is the query as narrow as the screen needs? - Is `includeMetadataChanges: true` backed by UI that reads the metadata?
- Can two widgets share one Firestore listener?Yes, if they subscribe to the same stream object. `snapshots()` returns a broadcast stream, and its native listener runs until the last subscriber cancels. A repository or provider that creates the stream once and hands it to every consumer gives many widgets one listener.
- When is includeMetadataChanges: true worth the extra events?When the UI actually uses the metadata — for example a saved indicator that waits for `hasPendingWrites` to become `false`, or an offline badge driven by `isFromCache`. Otherwise it only adds events for every local write and connectivity change.
saying these in an interview costs you the question
- snapshots() in build() is fine because the plugin deduplicates identical queries.
- A Firestore listener stops by itself when its widget leaves the screen.
- Calling snapshots() starts a native listener even if nobody listens.
- includeMetadataChanges: true is free and should always be enabled.
- Every widget needs its own snapshots() call to receive updates.