In mobile applications deployed across variable network environments—such as logistics, field inspections, or healthcare—treating connectivity as a continuous prerequisite leads to fragile user experiences.
An offline-first architecture treats local persistence as the single source of truth for presentation, while network operations execute asynchronously through a persistent sync queue.
Local databases serve immediate UI renders; the sync engine synchronizes deltas in the background.
Core Principles of Offline-First
- Immediate Local Writes: User actions mutate local storage synchronously. The UI updates instantaneously with zero network latency.
- Persistent Outbox Pattern: Outgoing mutations are enqueued in an SQLite
sync_queuetable before any network attempt. - Reactive Stream Binding: UI components subscribe directly to database table streams (via Drift/SQLite), never to raw HTTP responses.
- Idempotent Synchronization: Network sync endpoints accept UUIDs and monotonic version timestamps to guarantee safe retries.
Conflict Resolution Strategies
| Strategy | Mechanism | Best Suited For | Risk |
|---|---|---|---|
| Last-Write-Wins (LWW) | Highest timestamp overwrites state | Non-critical status flags, user preferences | Data loss on concurrent field edits |
| Two-Phase Outbox | Queue commands, replay in sequence | Order submission, payments, inventory | Sync stalls on corrupted payloads |
| Field-Level Merging | Compute diffs per column | User profiles, complex document forms | Increased sync payload complexity |
| Operational CRDTs | Conflict-Free Replicated Data Types | Collaborative notes, counters | Storage overhead on mobile clients |
Implementing Local Storage with Drift
Drift (formerly Moor) generates type-safe Dart APIs directly from relational SQLite schemas:
import 'package:drift/drift.dart';
part 'app_database.g.dart';
// 1. Table Definitions
class Tasks extends Table {
TextColumn get id => text()();
TextColumn get title => text().withLength(min: 1, max: 200)();
BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
IntColumn get syncStatus => integer().withDefault(const Constant(0))(); // 0 = synced, 1 = pending
@override
Set<Column> get primaryKey => {id};
}
class SyncMutations extends Table {
IntColumn get queueId => integer().autoIncrement()();
TextColumn get entityId => text()();
TextColumn get action => text()(); // INSERT, UPDATE, DELETE
TextColumn get payloadJson => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
@DriftDatabase(tables: [Tasks, SyncMutations])
class AppDatabase extends _$AppDatabase {
AppDatabase(QueryExecutor e) : super(e);
@override
int get schemaVersion => 1;
// Reactive stream for UI observation
Stream<List<Task>> watchAllTasks() {
return (select(tasks)..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])).watch();
}
// Atomic offline write
Future<void> createOfflineTask(String id, String title) {
return transaction(() async {
final now = DateTime.now();
await into(tasks).insert(
TasksCompanion.insert(
id: id,
title: title,
updatedAt: Value(now),
syncStatus: const Value(1), // Pending sync
),
);
await into(syncMutations).insert(
SyncMutationsCompanion.insert(
entityId: id,
action: 'INSERT',
payloadJson: '{"id": "$id", "title": "$title"}',
createdAt: Value(now),
),
);
});
}
}Background Outbox Dispatcher
A dedicated sync coordinator monitors network connectivity and flushes the outbox queue:
class SyncCoordinator {
final AppDatabase db;
final ApiClient api;
bool _isSyncing = false;
SyncCoordinator({required this.db, required this.api});
Future<void> flushPendingMutations() async {
if (_isSyncing) return;
_isSyncing = true;
try {
final pending = await (db.select(db.syncMutations)
..orderBy([(m) => OrderingTerm.asc(m.queueId)]))
.get();
for (final mutation in pending) {
final success = await api.dispatch(
action: mutation.action,
payload: mutation.payloadJson,
);
if (success) {
// Delete from queue and mark local entity synced
await (db.delete(db.syncMutations)..where((m) => m.queueId.equals(mutation.queueId))).go();
await (db.update(db.tasks)..where((t) => t.id.equals(mutation.entityId)))
.write(const TasksCompanion(syncStatus: Value(0)));
} else {
// Stop queue iteration on first network failure to preserve sequence
break;
}
}
} finally {
_isSyncing = false;
}
}
}Architectural Takeaways
By architecting around a local database and a command outbox queue, your Flutter app becomes resilient to subway tunnels, airplane modes, and spotty cellular networks.