Building Offline Mode in Flutter That Actually Works (Sync Queue Pattern)
Your app works great on WiFi. Users love it. Then someone opens it on a subway, in a parking garage, or in a country with spotty 3G — and everything breaks. Submissions fail silently. Data disappears. The spinner never stops.
"Just add offline support" sounds simple, until you try to build it. I wasted weeks on my first attempt because every tutorial I found only covered half the problem: caching API responses for read operations. Nobody talked about what happens when a user creates, updates, or deletes data while offline.
That's the hard part. And that's what we're going to solve.
Why Offline Mode Is Harder Than It Looks
Caching GET responses is the easy part. Packages like hive or shared_preferences handle that in 10 lines. The real challenge is mutations — what happens when a user:
- Creates a new record while offline
- Edits an existing record
- Deletes something
- Does all of the above, on multiple devices, before reconnecting
You need a sync queue: a local buffer that stores pending operations and replays them against the server when connectivity returns.
Common Mistakes That Waste Your Time
Mistake 1: Only Caching Reads
// ❌ This only solves half the offline problem
Future<List<Task>> getTasks() async {
try {
final response = await api.get('/tasks');
await localDb.saveTasks(response.data);
return response.data;
} catch (e) {
return await localDb.getTasks(); // Fallback to cache
}
}This pattern breaks the moment a user tries to create a task offline. The POST fails, the user sees an error (or worse, nothing), and the data is lost.
Mistake 2: Wrapping Every API Call in try/catch
// ❌ Error-swallowing spaghetti
Future<void> createTask(Task task) async {
try {
await api.post('/tasks', data: task.toJson());
} catch (e) {
// "We'll try again later" — but when? How?
print('Failed to create task: $e');
}
}You've caught the error but done nothing with it. The task exists in the user's mind but not in the database. When they refresh, it's gone.
Mistake 3: Using connectivity_plus As a Gate
// ❌ Connectivity check ≠ API reachability
final isOnline = await Connectivity().checkConnectivity();
if (isOnline != ConnectivityResult.none) {
await api.post('/tasks', data: task.toJson());
}connectivity_plus checks if you have a network interface — not whether the API is reachable. You can have full WiFi bars and still get SocketException because the server is down, DNS is broken, or a firewall is blocking the request. Never use connectivity state as a proxy for API availability.
The Solution: Sync Queue Architecture
Here's the pattern I use in production. It has three layers:
┌─────────────────────────────────────────┐
│ UI Layer │
│ (Reads from local DB, writes to queue)│
├─────────────────────────────────────────┤
│ Sync Queue │
│ (Buffers pending mutations) │
├─────────────────────────────────────────┤
│ Local Database │
│ (Source of truth while offline) │
├─────────────────────────────────────────┤
│ Sync Engine │
│ (Replays queue when online) │
├─────────────────────────────────────────┤
│ Remote API │
│ (Server, actual source of truth) │
└─────────────────────────────────────────┘
Step 1: Define the Sync Queue Item
Every mutation gets stored as a queue item with enough info to replay it later:
enum SyncAction { create, update, delete }
class SyncQueueItem {
final String id;
final String entityType; // 'task', 'note', 'invoice'
final String entityId;
final SyncAction action;
final Map<String, dynamic> payload;
final DateTime createdAt;
int retryCount;
SyncQueueItem({
required this.id,
required this.entityType,
required this.entityId,
required this.action,
required this.payload,
required this.createdAt,
this.retryCount = 0,
});
Map<String, dynamic> toJson() => {
'id': id,
'entityType': entityType,
'entityId': entityId,
'action': action.name,
'payload': payload,
'createdAt': createdAt.toIso8601String(),
'retryCount': retryCount,
};
}Step 2: Build the Queue Manager
This class handles all local mutations. It writes to the local DB immediately (so the UI updates), then adds the mutation to the sync queue:
class OfflineQueueManager {
final LocalDatabase db;
final Box<Map> syncQueue; // Hive box for the queue
OfflineQueueManager({required this.db, required this.syncQueue});
/// Save locally + queue for sync
Future<void> createTask(Task task) async {
// 1. Save to local DB immediately (UI sees the change)
await db.saveTask(task.copyWith(
syncStatus: SyncStatus.pending,
));
// 2. Add to sync queue
final queueItem = SyncQueueItem(
id: Uuid().v4(),
entityType: 'task',
entityId: task.id,
action: SyncAction.create,
payload: task.toJson(),
createdAt: DateTime.now(),
);
await syncQueue.put(queueItem.id, queueItem.toJson());
}
Future<void> updateTask(Task task) async {
await db.saveTask(task.copyWith(syncStatus: SyncStatus.pending));
final queueItem = SyncQueueItem(
id: Uuid().v4(),
entityType: 'task',
entityId: task.id,
action: SyncAction.update,
payload: task.toJson(),
createdAt: DateTime.now(),
);
await syncQueue.put(queueItem.id, queueItem.toJson());
}
Future<void> deleteTask(String taskId) async {
await db.deleteTask(taskId);
final queueItem = SyncQueueItem(
id: Uuid().v4(),
entityType: 'task',
entityId: taskId,
action: SyncAction.delete,
payload: {'id': taskId},
createdAt: DateTime.now(),
);
await syncQueue.put(queueItem.id, queueItem.toJson());
}
/// Get pending items count (for UI badge)
int get pendingCount => syncQueue.length;
}The critical insight here: the UI reads from the local database, not from the API. When a user creates a task offline, it appears in their list immediately because it was saved locally first.
Step 3: Build the Sync Engine
The sync engine processes the queue when connectivity is available:
class SyncEngine {
final ApiClient api;
final Box<Map> syncQueue;
final LocalDatabase db;
bool _isSyncing = false;
SyncEngine({required this.api, required this.syncQueue, required this.db});
/// Call this when connectivity changes or on app resume
Future<void> processQueue() async {
if (_isSyncing) return; // Prevent concurrent syncs
_isSyncing = true;
try {
// Process items in chronological order
final items = syncQueue.values
.map((v) => SyncQueueItem.fromJson(Map<String, dynamic>.from(v)))
.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
for (final item in items) {
try {
await _processItem(item);
await syncQueue.delete(item.id); // Remove from queue on success
// Update local record sync status
await db.updateSyncStatus(
item.entityType, item.entityId, SyncStatus.synced,
);
} catch (e) {
item.retryCount++;
if (item.retryCount >= 5) {
// Move to dead letter queue after 5 failed attempts
await _handleFailedItem(item);
await syncQueue.delete(item.id);
} else {
await syncQueue.put(item.id, item.toJson());
}
break; // Stop processing — maintain order
}
}
} finally {
_isSyncing = false;
}
}
Future<void> _processItem(SyncQueueItem item) async {
switch (item.action) {
case SyncAction.create:
await api.post('/${item.entityType}s', data: item.payload);
break;
case SyncAction.update:
await api.put(
'/${item.entityType}s/${item.entityId}',
data: item.payload,
);
break;
case SyncAction.delete:
await api.delete('/${item.entityType}s/${item.entityId}');
break;
}
}
Future<void> _handleFailedItem(SyncQueueItem item) async {
// Log to crash reporting, notify user, or save to error store
await db.updateSyncStatus(
item.entityType, item.entityId, SyncStatus.failed,
);
}
}Two critical details here:
_isSyncingmutex — Without this, rapid connectivity changes trigger multiple sync cycles that process the same items.breakon failure — We stop processing to maintain order. If item #2 creates a subtask for item #1's task, item #1 must succeed first.
Step 4: Trigger Sync on Connectivity Changes
class ConnectivityWatcher {
final SyncEngine syncEngine;
late StreamSubscription _subscription;
ConnectivityWatcher({required this.syncEngine}) {
_subscription = Connectivity()
.onConnectivityChanged
.listen((result) {
if (result != ConnectivityResult.none) {
// Connectivity changed — try syncing
// But verify with an actual API call
_verifySyncAndProcess();
}
});
}
Future<void> _verifySyncAndProcess() async {
try {
// Ping the actual API, not just check WiFi
await api.get('/health').timeout(Duration(seconds: 5));
await syncEngine.processQueue();
} catch (e) {
// API not reachable even though we have connectivity
// Don't sync — wait for next change
}
}
void dispose() => _subscription.cancel();
}Also trigger sync on AppLifecycleState.resumed — users often switch between apps after regaining connectivity:
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
syncEngine.processQueue();
}
}Step 5: Show Sync Status in the UI
Users need to know what's happening. Add subtle indicators:
// Show pending sync count
StreamBuilder<int>(
stream: queueManager.pendingCountStream,
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
if (count == 0) return SizedBox.shrink();
return Chip(
avatar: Icon(Icons.sync, size: 16),
label: Text('$count pending'),
backgroundColor: Colors.orange.shade100,
);
},
)Mark individual items with their sync status:
ListTile(
title: Text(task.title),
trailing: task.syncStatus == SyncStatus.pending
? Icon(Icons.cloud_off, size: 16, color: Colors.orange)
: task.syncStatus == SyncStatus.failed
? Icon(Icons.error, size: 16, color: Colors.red)
: null, // Synced items show no icon
)Handling Conflicts
What if a user edits a task offline, and someone else edits the same task on the server? You need a conflict resolution strategy:
Last-write-wins (simple):
// Server accepts the latest timestamp
await api.put('/tasks/$id', data: {
...taskData,
'updatedAt': DateTime.now().toIso8601String(),
});Server-side merge (robust):
// Send both versions, let the server decide
await api.put('/tasks/$id/resolve', data: {
'clientVersion': localTask.toJson(),
'baseVersion': lastSyncedVersion.toJson(),
});For most apps, last-write-wins is good enough. Don't over-engineer conflict resolution until your users actually need collaborative editing.
Key Takeaways
- Offline mode isn't just caching reads. The hard part is queuing mutations.
- Write to local DB first, then queue for sync. The UI should never wait for the network.
- Don't trust
connectivity_plusalone. Always verify with an actual API call. - Process the queue in order. Dependent operations must maintain sequence.
- Show users their sync status. A small "pending" badge builds trust.
- Start with last-write-wins. Only build complex conflict resolution when you need it.
- Use a dead letter queue. Don't retry failed items forever.
I built this pattern after shipping a field services app where technicians work in basements, rural areas, and underground facilities — all with zero connectivity. If your app works there, it works everywhere.
Find me at: itszain.dev