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.
flutter pub add drift drift_flutter
flutter pub add --dev build_runner drift_devDefine your schema:
class Tasks extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get title => text().withLength(min: 1, max: 200)();
TextColumn get status => text().withDefault(const Constant('pending'))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
TextColumn get syncId => text().nullable()(); // server-assigned ID
BoolColumn get needsSync => boolean().withDefault(const Constant(true))();
}
@DriftDatabase(tables: [Tasks])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(driftDatabase(name: 'app.db'));
@override
int get schemaVersion => 1;
// Reactive query — UI rebuilds automatically when data changes
Stream<List<Task>> watchAllTasks() =>
(select(tasks)..orderBy([(t) => OrderingTerm.desc(t.createdAt)])).watch();
}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');
}
}
}
}Wire it up with the connectivity stream:
class SyncManager {
StreamSubscription? _sub;
void start(ConnectivityService connectivity, SyncEngine engine) {
_sub = connectivity.isOnline.listen((online) {
if (online) engine.sync();
});
}
void dispose() => _sub?.cancel();
}Handling Conflicts
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 schema
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
// Server picks whichever has the later updatedAtServer 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:
class SyncIndicator extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final pendingCount = ref.watch(pendingSyncCountProvider);
if (pendingCount == 0) return const SizedBox();
return Row(
children: [
const Icon(Icons.cloud_upload_outlined, size: 14, color: Colors.amber),
const SizedBox(width: 4),
Text(
'$pendingCount unsynced',
style: const TextStyle(fontSize: 12, color: Colors.amber),
),
],
);
}
}The Offline-First Checklist
- All reads come from local database, not the network
- All writes go to local database first, then sync
- Unsynced items are tracked with a
needsSyncflag - 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.