← Articles

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.

Offline-first Flutter apps with Firestore — ANN Tech