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.