Most mobile apps assume a reliable internet connection. In the real world — spotty 3G, underground metros, rural areas, airplane mode — that assumption breaks constantly. Offline-first means your app works fully without connectivity and syncs seamlessly when it returns.
Here's how to build it.
The Core Pattern
Offline-first follows a simple rule: read from local storage, write to local storage, sync with server in the background.
User action → Write to local DB → Update UI → Queue sync job
↓
Server ← Sync when online
The user sees instant responses because they're always reading from local data. The server eventually gets consistent.
Choose Your Local Database
Drift (formerly Moor) is my recommendation for Flutter — it's a type-safe SQLite wrapper with generated code, reactive queries, and migration support.
The needsSync flag is key — any row written locally gets flagged for sync.
Track Connectivity
Use connectivity_plus to know when you're online:
import 'package:connectivity_plus/connectivity_plus.dart';class ConnectivityService { final _connectivity = Connectivity(); Stream<bool> get isOnline => _connectivity.onConnectivityChanged .map((result) => result != ConnectivityResult.none); Future<bool> get currentlyOnline async { final result = await _connectivity.checkConnectivity(); return result != ConnectivityResult.none; }}
The Sync Engine
When connectivity returns, upload pending local changes to the server:
class SyncEngine { final AppDatabase _db; final ApiClient _api; SyncEngine(this._db, this._api); Future<void> sync() async { final pending = await _db.getPendingTasks(); for (final task in pending) { try { if (task.syncId == null) { // New item — create on server final serverTask = await _api.createTask(task); await _db.updateTask( task.copyWith(syncId: serverTask.id, needsSync: false), ); } else { // Existing item — update on server await _api.updateTask(task.syncId!, task); await _db.markSynced(task.id); } } catch (e) { // Don't crash the sync — move on, retry next time log('Sync failed for task ${task.id}: $e'); } } }}
When the same item is edited on two devices while offline, you have a conflict. Three strategies:
Last write wins — simplest. Whoever synced last is correct. Good for personal apps where one user uses one device at a time.
// Add updatedAt to your schemaDateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();// Server picks whichever has the later updatedAt
Server wins — on conflict, the server version overwrites local. Good for read-heavy apps where the server is the source of truth (news feeds, catalogs).
Manual merge — surface the conflict to the user and let them choose. Complex to build but necessary for collaborative apps (notes, documents).
For most apps, last write wins with an updatedAt timestamp is the right choice.
Never use device time as the sole source of truth for conflict resolution. Device clocks can be wrong or manipulated. Use server timestamps for the final comparison, and treat local timestamps as hints only.
Idempotency Keys
Network requests can fail after the server has already processed them (the response was lost in transit). Without idempotency keys, retrying creates duplicates.
import 'package:uuid/uuid.dart';class TaskRepository { Future<void> createTask(TaskCompanion task) async { final idempotencyKey = const Uuid().v4(); // Store key locally so we can retry with the same key await _db.into(_db.tasks).insert( task.copyWith(idempotencyKey: Value(idempotencyKey)), ); // Server uses this key to deduplicate await _api.createTask(task, idempotencyKey: idempotencyKey); }}
The server checks if it's seen this key before and returns the existing result instead of creating a duplicate.
Show Sync Status in the UI
Users need to know when their data isn't synced yet. A subtle indicator is enough:
All reads come from local database, not the network
All writes go to local database first, then sync
Unsynced items are tracked with a needsSync flag
Sync runs automatically when connectivity returns
Idempotency keys prevent duplicate creates on retry
Conflicts are handled (even if just "last write wins")
UI shows pending sync count when items are unsynced
Tests cover the offline → sync flow, not just the happy path
Start with this pattern and you'll handle the 80% of offline scenarios that matter. Add complexity (manual merge, partial sync, delta sync) only when the product actually needs it.