skip to content

In a Flutter app using cloud_firestore, why is calling snapshots() inside build() costly, and how do you keep Firestore listeners cheap?

level: seniorimportance: should knowfreq 40%

answer

  1. one stream per call
  2. native listener starts on first listen
  3. torn down when the last subscriber cancels
  4. new stream object, new initial result
  5. includeMetadataChanges doubles local-write events

basics

~20 s

Each 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 s

In 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 lines
dart
import '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

for a junior

Remember never to create a Firestore stream inside build(), and to cancel any subscription you open yourself.

for a middle

Explain the lifecycle: stream per call, native listener on first listen, teardown on last cancel, and why a new stream object restarts it.

for a senior

Find listener leaks and rebuild restarts in a real screen, narrow queries, share one stream across consumers, and justify each metadata listener.

for a principal

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.