Skip to content
ExponaBLOG
FLUTTER · DAY 046 / 100

Offline-first apps: what every Flutter developer should know

Apps that work without a signal.

Expona Admin8 min read

Most Flutter apps assume perfect network connectivity, but real users live in the subway, boarding flights, and moving between WiFi zones. Offline-first apps are designed around the opposite assumption: the network is unreliable, so your UI reads from local storage first, syncs changes in the background, and never leaves data stranded. This approach transforms user experience from "sorry, no connection" into "working offline—changes will sync automatically."

I've built this pattern into several production systems, and it's the difference between an app that feels responsive and one that feels broken to users in poor signal areas. Let me walk you through the architecture.

Local database first — UI reads from local storage, always

The foundation of offline-first design is inverting the typical data flow. Instead of fetching from the server and caching, you treat your local database as the source of truth and sync with the server as a background concern.

In Flutter, this means Hive, Isar, or SQLite becomes your primary store. When the app launches, the UI populates immediately from local data—no loading spinners waiting for a network request. Users see cached data from their last session instantly.

Consider a ride-hailing app I built. When a driver views active bookings, the UI reads from Isar first:

dart
class BookingRepository {
  final IsarCollection<Booking> bookings;
  
  // UI always reads from local storage
  Stream<List<Booking>> getActiveBookings() {
    return bookings
        .filter()
        .statusEqualTo('active')
        .watch(fireImmediately: true);
  }
  
  // Sync happens separately
  Future<void> syncBookings() async {
    try {
      final remote = await api.fetchBookings();
      await bookings.writeTxn(() async {
        await bookings.clear();
        await bookings.putAll(remote);
      });
    } catch (e) {
      // Sync failed, but UI still shows local data
      print('Sync error: $e');
    }
  }
}

The key insight: the UI layer never waits for the network. A StreamBuilder watching that local collection fires immediately with cached data, even if sync is still pending. Users see the bookings list on app open without delay.

This local-first read pattern also means your app handles connection loss gracefully—the UI doesn't blank out or show an error state. It keeps showing the last known state.

Sync in the background — Push local changes and pull remote updates

Once your local database is the source of truth, you need a background sync mechanism that periodically pushes local changes and pulls remote updates, independent of the UI.

I typically implement this as a separate service that runs on a timer or responds to connectivity changes. When the device regains connection, it wakes up and reconciles state.

Here's a practical sync strategy for a travel booking app I worked on:

dart
class SyncService {
  final BookingRepository repo;
  final ApiClient api;
  late Timer syncTimer;
  late Connectivity connectivity;
  
  void init() {
    // Sync every 30 seconds when connected
    syncTimer = Timer.periodic(Duration(seconds: 30), (_) {
      _performSync();
    });
    
    // Also sync on connectivity changes
    connectivity.onConnectivityChanged.listen((result) {
      if (result != ConnectivityResult.none) {
        _performSync();
      }
    });
  }
  
  Future<void> _performSync() async {
    try {
      // Push local changes first
      await _pushLocalMutations();
      
      // Then pull remote updates
      await repo.syncBookings();
      
      print('Sync completed successfully');
    } catch (e) {
      print('Sync failed: $e');
      // Retry on next interval—don't panic
    }
  }
  
  Future<void> _pushLocalMutations() async {
    final pending = await repo.getPendingMutations();
    for (final mutation in pending) {
      try {
        await api.applyMutation(mutation);
        await repo.markMutationSynced(mutation.id);
      } catch (e) {
        // If push fails, keep it in queue for retry
        print('Failed to push mutation ${mutation.id}: $e');
      }
    }
  }
  
  void dispose() {
    syncTimer.cancel();
  }
}

The sync service runs independently of the UI thread. It doesn't block user interactions. If the user opens the app offline, the sync service waits quietly for connectivity and reconciles when available.

I often pair this with a background task runner like workmanager on Android and iOS to survive app termination. This ensures sync continues even if the user force-closes the app.

Queue mutations — Store actions offline and replay them later

When users act offline—accepting a booking, drafting a message, updating their profile—you can't send those changes to the server immediately. Instead, queue them locally and replay when connectivity returns.

The pattern is simple: when a user performs an action, write both the mutation record and the optimistic update to local storage, then attempt to sync. If sync fails, the mutation stays in the queue.

From a CRM system I built, here's how I structured mutation queueing:

dart
class MutationQueue {
  final Isar isar;
  
  // User updates their profile offline
  Future<void> queueMutation(UserUpdate update) async {
    final mutation = Mutation(
      id: uuid.v4(),
      type: 'user_update',
      payload: jsonEncode(update),
      createdAt: DateTime.now(),
      synced: false,
    );
    
    await isar.writeTxn(() async {
      // Store the mutation record
      await isar.mutations.put(mutation);
      
      // Optimistically update local user
      final user = await isar.users.get(update.userId);
      if (user != null) {
        user.name = update.name;
        user.email = update.email;
        await isar.users.put(user);
      }
    });
  }
  
  // Replay queued mutations when syncing
  Future<void> replayMutations() async {
    final pending = await isar.mutations
        .filter()
        .syncedEqualTo(false)
        .findAll();
    
    for (final mutation in pending) {
      try {
        await _applyMutation(mutation);
        
        // Mark synced after successful push
        mutation.synced = true;
        await isar.writeTxn(() => isar.mutations.put(mutation));
      } catch (e) {
        // Leave in queue for next retry
        print('Replay failed for ${mutation.id}: $e');
        rethrow;
      }
    }
  }
  
  Future<void> _applyMutation(Mutation mutation) async {
    final payload = jsonDecode(mutation.payload);
    // Send to API
    await api.post('/mutations', payload);
  }
}

This approach is powerful because it separates concerns: the UI code doesn't worry about connectivity. It just calls queueMutation, gets an instant local update, and the background sync handles the rest. If the mutation fails to sync, it stays queued and retries on the next sync cycle.

Order matters here. I always replay mutations in the order they were created to maintain consistency. If the user accepted a booking then updated their status offline, replaying in that order ensures the server sees the same causality.

Show sync status — Users should know what's pending

Offline-first doesn't mean invisible. Users should understand whether their data is synced, pending, or conflicted. A small status indicator prevents confusion and builds trust.

I implement this as a Riverpod stream that the UI watches:

dart
enum SyncState { synced, syncing, offline, error }

final syncStateProvider = StreamProvider<SyncState>((ref) {
  final syncService = ref.watch(syncServiceProvider);
  return syncService.syncStateStream;
});

class SyncService {
  final _syncStateController = StreamController<SyncState>.broadcast();
  Stream<SyncState> get syncStateStream => _syncStateController.stream;
  
  Future<void> _performSync() async {
    _syncStateController.add(SyncState.syncing);
    try {
      await _pushLocalMutations();
      await _pullRemoteUpdates();
      _syncStateController.add(SyncState.synced);
    } catch (e) {
      _syncStateController.add(SyncState.error);
    }
  }
}

Then in your widget:

dart
class SyncIndicator extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final syncState = ref.watch(syncStateProvider);
    
    return syncState.when(
      data: (state) {
        return switch (state) {
          SyncState.synced =>
            Icon(Icons.cloud_done, color: Colors.green),
          SyncState.syncing =>
            SizedBox(
              width: 24,
              height: 24,
              child: CircularProgressIndicator(strokeWidth: 2),
            ),
          SyncState.offline =>
            Icon(Icons.cloud_off, color: Colors.grey),
          SyncState.error =>
            Icon(Icons.cloud_upload_outlined, color: Colors.red),
        };
      },
      loading: () => SizedBox(width: 24, height: 24, child: CircularProgressIndicator()),
      error: (_, __) => Icon(Icons.error),
    );
  }
}

I also show a banner when offline or when mutations are pending, so users don't wonder why their changes haven't propagated to other devices yet. A simple message like "Changes syncing..." builds confidence that the system is working.

Common mistakes

  • Syncing on every change: Calling your API for each keystroke or tap kills battery and wastes bandwidth. Batch mutations and sync on intervals or connectivity changes instead.
  • Deleting local data too aggressively: If you clear your local database on every sync, users with unreliable connections lose their working copy. Keep local data until the server confirms success.
  • Ignoring conflict resolution: If the user edits data offline and the server version changed, which wins? Define a strategy—last-write-wins, server-wins, or user-prompted merge—and implement it explicitly.
  • Not testing offline scenarios: Use Android Emulator's network throttling or iOS Simulator's offline mode during development. Many bugs only appear when the network is actually unavailable.
  • Forgetting to clean up synced mutations: Old mutation records pile up in your local database. Archive or delete them after they've synced for 7+ days.

Key takeaways

  • Treat local storage as your primary data source. The UI reads from Isar, Hive, or SQLite first—never wait for the network for initial display.
  • Run sync as a background service independent of the UI. Use timers and connectivity listeners to trigger reconciliation when appropriate.
  • Queue mutations locally before attempting to push. Store both the action record and an optimistic local update so the UI feels instant.
  • Display sync status clearly. Users need to know whether changes are pending, syncing, or confirmed so they don't repeat actions.
  • Test offline thoroughly. Force the app into airplane mode during development and verify that data isn't lost and mutations replay correctly.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles