Offline-first Flutter apps with Firestore
By John · 2 April 2025
Offline-first means your app works without internet — data changes are stored locally and synced to Firestore when connectivity is restored. Firestore makes this surprisingly straightforward because it has built-in offline persistence.
Why offline-first matters
Users don't stop using apps when they lose signal. Subway rides, flights, rural areas, or simply bad WiFi — your app needs to handle these gracefully. An offline-first app doesn't show spinners or error screens; it shows cached data and queues changes.
Enabling Firestore persistence
import 'package:cloud_firestore/cloud_firestore.dart';
Future<void> initFirestore() async {
// Enable offline persistence (default on mobile, must be enabled on web)
FirebaseFirestore.instance.settings = const Settings(
persistenceEnabled: true,
cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
);
}
With persistence enabled:
- Reads return cached data when offline
- Writes are queued locally and automatically sent when online
- Real-time listeners continue to emit cached data offline
Reading with cache-first strategy
// Will return cached data immediately, then live data when available
Stream<QuerySnapshot> getOrdersStream() {
return FirebaseFirestore.instance
.collection('orders')
.orderBy('createdAt', descending: true)
.snapshots(includeMetadataChanges: true); // Get notified of cache vs server
}
// In widget:
StreamBuilder<QuerySnapshot>(
stream: repo.getOrdersStream(),
builder: (context, snapshot) {
if (!snapshot.hasData) return const CircularProgressIndicator();
final isFromCache = snapshot.data!.metadata.isFromCache;
return Column(
children: [
if (isFromCache)
const Banner(message: 'Offline — showing cached data', location: BannerLocation.topEnd),
OrdersList(docs: snapshot.data!.docs),
],
);
},
)
Forced cache read
For initial screen load where stale data is fine:
Future<List<Order>> getOrdersCached() async {
try {
final snap = await FirebaseFirestore.instance
.collection('orders')
.get(const GetOptions(source: Source.cache));
return snap.docs.map(Order.fromFirestore).toList();
} catch (_) {
// Cache empty, fall back to server
final snap = await FirebaseFirestore.instance
.collection('orders')
.get(const GetOptions(source: Source.server));
return snap.docs.map(Order.fromFirestore).toList();
}
}
Tracking connectivity
Show offline indicators when the user is offline:
import 'package:connectivity_plus/connectivity_plus.dart';
final connectivityProvider = StreamProvider<bool>((ref) {
return Connectivity()
.onConnectivityChanged
.map((result) => result != ConnectivityResult.none);
});
// In widget:
final isOnline = ref.watch(connectivityProvider).value ?? true;
if (!isOnline) {
return const OfflineBanner();
}
Queuing writes with hive fallback
Firestore queues writes automatically, but you don't know if a write succeeded. For critical operations, store a pending flag locally:
class OrderRepository {
final _pendingBox = Hive.box<String>('pending_orders');
Future<void> createOrder(Order order) async {
// Save locally first
await _pendingBox.put(order.id, jsonEncode(order.toJson()));
try {
await FirebaseFirestore.instance
.collection('orders')
.doc(order.id)
.set(order.toJson());
// Firestore confirmed write
await _pendingBox.delete(order.id);
} catch (e) {
// Write failed — Firestore will retry when online
// The pending box entry serves as our indicator
}
}
List<String> get pendingOrderIds => _pendingBox.keys.cast<String>().toList();
}
Conflict resolution
Firestore's "last write wins" is the default. For more control, use field-level timestamps:
await orderRef.set({
'status': 'shipped',
'statusUpdatedAt': FieldValue.serverTimestamp(),
'updatedAt': FieldValue.serverTimestamp(),
});
Never use client-side DateTime.now() for timestamps you'll compare across devices — it depends on device clock accuracy. FieldValue.serverTimestamp() is set by Firestore servers.
Clearing the cache
Firestore's cache grows over time. Clear it on logout:
Future<void> signOut() async {
await FirebaseAuth.instance.signOut();
await FirebaseFirestore.instance.clearPersistence();
// clearPersistence() must be called before the Firestore instance is used again
}
Testing offline behaviour
Flutter's network emulation:
# Disable network in Android emulator
adb shell svc wifi disable
adb shell svc data disable
# Re-enable
adb shell svc wifi enable
adb shell svc data enable
Or test cache-only reads in widget tests:
// Mock Firestore to simulate offline
final mockFirestore = FakeFirebaseFirestore();
await mockFirestore.collection('orders').add(order.toJson());
// Test that UI shows cached data
Common pitfalls
Not enabling persistence explicitly on web. Offline persistence is on by default for mobile but must be explicitly enabled for web apps with initializeFirestore(app, { localCache: persistentLocalCache() }).
Treating Firestore as the only offline layer. Firestore's cache is not designed for large binary files or complex local querying. For offline images, use a separate local file cache. For complex local queries, add Drift or Hive alongside Firestore.
Not communicating offline state to users. Silent offline behaviour confuses users who expect their changes to be saved immediately. Show an offline banner and confirm when pending changes sync.
Sign in to like, dislike, or report.