Building Offline-First Mobile Apps: Architecture and Sync Patterns
Mobile apps live in an unreliable world. Users open your app in subway tunnels, in buildings with thick walls, and in areas with spotty cellular coverage. If your app requires a network connection to function, you're already delivering a poor experience to a significant portion of your users.
Offline-first is an architectural approach where the local device is the primary data source and the server is treated as a synchronization target. The app works fully without connectivity and syncs data when a connection is available — a pattern I used extensively when building PeptiSync for logging health data without connectivity.
The Offline-First Mindset
Traditional apps work like this:
`` User Action → API Request → Server → Response → Update UI ``
Offline-first reverses the flow:
`` User Action → Local Database → Update UI → Queue Sync → Server ``
The key insight: the user should never wait for the network. Local writes are instant. The server catches up when it can.
Core Architecture
Local Database
Choose a local database based on your data complexity:
| Database | Best For | Notes |
|---|---|---|
| SQLite (via sqflite, drift) | Relational data, complex queries | Mature, well-understood |
| Realm | Object-oriented data | Fast, but SDK churn issues |
| Firestore Offline Persistence | Firebase apps | Enabled with one line of code |
| WatermelonDB | High-performance lists | SQLite-based, great for large datasets |
For a Firebase-based app, Firestore's offline persistence is the most straightforward option:
``dart // Flutter with Firestore void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(); await FirebaseFirestore.instance.enablePersistence(); runApp(MyApp()); } ``
Sync Queue Architecture
When offline writes can't be sent immediately, they go into a queue:
```dart class SyncQueue { final List<SyncOperation> _queue = []; bool _isSyncing = false;
Future<void> enqueue(SyncOperation operation) async { _queue.add(operation); await _persistQueue(); await processQueue(); }
Future<void> processQueue() async { if (_isSyncing || _queue.isEmpty) return; _isSyncing = true;
while (_queue.isNotEmpty) { final operation = _queue.first; try { await operation.execute(); _queue.removeAt(0); await _persistQueue(); } on NetworkException { break; // Still offline, stop processing } on SyncConflictException { await _resolveConflict(operation); _queue.removeAt(0); await _persistQueue(); } }
_isSyncing = false; } } ```
Conflict Resolution Strategies
Conflicts occur when the same data is modified on two devices. Three strategies handle most scenarios:
1. Last Write Wins (LWW)
The simplest strategy. The most recent timestamp wins. Suitable for independent data:
```dart class Document { final String id; final Map<String, dynamic> data; final DateTime updatedAt; }
void resolveLWW(Document local, Document remote) { if (local.updatedAt.isAfter(remote.updatedAt)) { uploadToServer(local); } else { updateLocal(remote); } } ```
Firestore uses LWW by default with server timestamps.
2. Operational Transformation (OT)
Used for collaborative editing (Google Docs-style). Each operation is transformed against concurrent operations:
```dart class Operation { final String type; // 'insert', 'delete', 'update' final int position; final String value; }
Operation transform(Operation op1, Operation op2) { if (op1.position < op2.position) return op1; if (op1.type == 'insert' && op2.type == 'insert') { return Operation( type: 'insert', position: op1.position + op2.value.length, value: op1.value, ); } // Additional transformation rules... } ```
3. CRDT (Conflict-Free Replicated Data Types)
The most sophisticated approach. Data types are designed so concurrent modifications always merge deterministically:
```dart class Counter { final Map<String, int> replicaCounts = {};
void increment(String replicaId) { replicaCounts[replicaId] = (replicaCounts[replicaId] ?? 0) + 1; }
int get total => replicaCounts.values.fold(0, (a, b) => a + b);
void merge(Counter other) { for (final entry in other.replicaCounts.entries) { replicaCounts[entry.key] = max( replicaCounts[entry.key] ?? 0, entry.value, ); } } } ```
Network Awareness
Detect connectivity changes and adapt the UI accordingly:
```dart import 'package:connectivity_plus/connectivity_plus.dart';
class ConnectivityService { final _connectivity = Connectivity(); final _status = ValueNotifier<bool>(true);
Stream<bool> get onStatusChange => _status.stream;
ConnectivityService() { _connectivity.onConnectivityChanged.listen((results) { _status.value = results.any((r) => r != ConnectivityResult.none); }); } } ```
UI States for Offline
Communicate offline state to users without annoying them:
``dart class OfflineBanner extends StatelessWidget { @override Widget build(BuildContext context) { return ValueListenableBuilder<bool>( valueListenable: connectivityService.status, builder: (context, isOnline, child) { if (isOnline) return SizedBox.shrink(); return MaterialBanner( content: Text('You\'re offline. Changes will sync when connected.'), leading: Icon(Icons.wifi_off), actions: [ TextButton( onPressed: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), child: Text('Dismiss'), ), ], ); }, ); } } ``
Firestore Offline Integration
Firestore's native offline support handles most of this automatically:
```dart // Reading with offline support final docRef = FirebaseFirestore.instance.collection('notes').doc('note1');
// get() returns cached data immediately, then updates from server final doc = await docRef.get(const GetOptions(source: Source.cache));
// Real-time listener works offline — fires with cached data docRef.snapshots().listen((snapshot) { if (snapshot.exists) { setState(() => data = snapshot.data()!); } });
// Writing offline — queued automatically await docRef.set({'title': 'Offline note', 'updatedAt': FieldValue.serverTimestamp()}); ```
Important Limitations
- Firestore's offline cache has a default size limit of ~40MB (adjustable on mobile)
- Queries with
array-containson offline data may return incomplete results - Transactions don't work offline — use batched writes instead
Testing Offline Behavior
Test your app's offline behavior systematically:
```dart void main() { group('SyncQueue', () { test('queues operations when offline', () async { final queue = SyncQueue(); await queue.setConnectivity(false);
await queue.enqueue(createNoteOperation('Test note')); expect(queue.pendingCount, equals(1)); });
test('processes queue when back online', () async { final queue = SyncQueue(); await queue.setConnectivity(false); await queue.enqueue(createNoteOperation('Test note'));
await queue.setConnectivity(true); expect(queue.pendingCount, equals(0)); });
test('handles conflict on sync', () async { final queue = SyncQueue(); queue.onConflict = ConflictResolution.lww;
await queue.enqueue(conflictingOperation); // Should resolve without throwing }); }); } ```
Conclusion
Offline-first architecture is not optional for mobile apps — it's a fundamental requirement that users expect. Start with Firestore's built-in offline persistence for simplicity, add a sync queue for reliable delivery, and choose a conflict resolution strategy that matches your data model. The investment in offline capability pays immediate dividends in user satisfaction and retention.
Building an offline-first mobile app? Let's talk about architecting a resilient sync layer for your project.
---