← Articles

Firestore real-time updates in Flutter

By Charlin Joe · 17 March 2025

Firestore's real-time listeners push data to your Flutter app instantly when documents change. No polling needed — the SDK handles reconnection, caching, and offline support.

Listen to a document

StreamSubscription<DocumentSnapshot>? _sub;

void startListening(String userId) {
  _sub = FirebaseFirestore.instance
      .collection('users')
      .doc(userId)
      .snapshots()
      .listen((snapshot) {
        if (!snapshot.exists) {
          // Document deleted
          return;
        }
        final user = User.fromJson(snapshot.data()!);
        // Update state
      }, onError: (error) {
        // Handle error (permissions, network)
      });
}

void dispose() => _sub?.cancel();

Listen to a collection query

Stream<List<Order>> getOrdersStream(String userId) {
  return FirebaseFirestore.instance
      .collection('orders')
      .where('userId', isEqualTo: userId)
      .orderBy('createdAt', descending: true)
      .limit(50)
      .snapshots()
      .map((snapshot) => snapshot.docs
          .map((doc) => Order.fromJson({'id': doc.id, ...doc.data()}))
          .toList());
}

With Riverpod

@riverpod
Stream<List<Order>> userOrders(UserOrdersRef ref) {
  final userId = ref.watch(authStateProvider).requireValue!.uid;
  return FirebaseFirestore.instance
      .collection('orders')
      .where('userId', isEqualTo: userId)
      .orderBy('createdAt', descending: true)
      .snapshots()
      .map((snap) => snap.docs
          .map((d) => Order.fromJson({'id': d.id, ...d.data()}))
          .toList());
}

// Widget:
class OrdersScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final ordersAsync = ref.watch(userOrdersProvider);
    return ordersAsync.when(
      data: (orders) => OrderList(orders: orders),
      loading: () => const CircularProgressIndicator(),
      error: (e, _) => Text('Error: $e'),
    );
  }
}

Listen for metadata changes (pending writes)

FirebaseFirestore.instance
    .collection('orders')
    .doc(orderId)
    .snapshots(includeMetadataChanges: true)
    .listen((snapshot) {
      final isPending = snapshot.metadata.hasPendingWrites;
      // Show 'Saving...' indicator while pending
    });

Handling offline

Firestore caches data locally and keeps listeners active offline:

// Enable persistence (on by default for mobile)
FirebaseFirestore.instance.settings = const Settings(
  persistenceEnabled: true,
  cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
);

// Check if data came from cache
.snapshots().listen((snapshot) {
  final isFromCache = snapshot.metadata.isFromCache;
  if (isFromCache) {
    // Data is cached, may be stale
  }
})

Batch writes + real-time

Writes appear in listeners immediately (local echo) before they're confirmed by the server:

// This write is reflected in the stream immediately
await FirebaseFirestore.instance
    .collection('orders')
    .doc(orderId)
    .update({'status': 'processing'});

// The stream listener fires with the new status right away
// hasPendingWrites will be true until server confirms

Unsubscribing correctly

Always cancel subscriptions to prevent memory leaks:

// With a Riverpod provider — cancellation is automatic
@riverpod
Stream<Order> orderDetail(OrderDetailRef ref, String orderId) {
  // Riverpod cancels the stream when the provider is disposed
  return FirebaseFirestore.instance
      .collection('orders')
      .doc(orderId)
      .snapshots()
      .map((s) => Order.fromJson({'id': s.id, ...s.data()!}));
}

// With StreamBuilder — cancellation is automatic
StreamBuilder<List<Order>>(
  stream: getOrdersStream(userId),
  builder: (context, snapshot) { ... },
)

// With manual subscription — you must cancel:
class _OrdersState extends State<OrdersScreen> {
  late StreamSubscription _sub;

  @override
  void initState() {
    super.initState();
    _sub = getOrdersStream(widget.userId).listen((orders) {
      setState(() => _orders = orders);
    });
  }

  @override
  void dispose() {
    _sub.cancel(); // MUST cancel
    super.dispose();
  }
}

Costs and limits

Each snapshots() listener counts reads:

  • Initial data load: 1 read per document returned
  • Each update: 1 read per changed document
  • Unchanged documents in a query don't incur reads on updates

To reduce reads on active listeners:

  • Filter queries as specifically as possible (where, limit)
  • Don't open listeners to entire collections
  • Consider whether real-time is needed — for some data, polling is cheaper

Common pitfalls

Opening a listener for every list item. If you listen to 50 order documents individually instead of a collection query, you use 50× the quota and have 50 active connections. Always use collection queries with where filters.

Not canceling listeners in StatefulWidget. A listener that isn't canceled in dispose() fires after the widget is unmounted, causing setState() called after dispose() errors. Always cancel in dispose() or use StreamBuilder/Riverpod which handle this automatically.

Re-creating the stream on every build. If you call collection('orders').snapshots() directly in a StreamBuilder's stream parameter, a new listener is created on every rebuild. Extract the stream to a method or provider so it's created once.

Sign in to like, dislike, or report.

Firestore real-time updates in Flutter — ANN Tech