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:
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:
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:
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:
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:
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.






Comments
Loading comments…