Offline-first architecture for Flutter apps
By Ann Tech · 22 March 2026
Offline-first means the app reads from and writes to a local database first, then syncs with the server in the background. The user never waits on a network call to see data — the app is always responsive.
The offline-first read pattern
User opens screen
→ Read from local DB immediately → Show data (may be stale)
→ Fetch from network in background
→ Update local DB → UI updates automatically via stream
The opposite (network-first) leaves users on slow networks staring at a spinner before seeing anything.
Local database with Drift
@DriftDatabase(tables: [Orders, Products])
class AppDatabase extends _$AppDatabase {
AppDatabase(QueryExecutor e) : super(e);
@override
int get schemaVersion => 1;
Stream<List<Order>> watchOrders() => select(orders).watch();
Future<void> upsertOrder(OrdersCompanion order) =>
into(orders).insertOnConflictUpdate(order);
Future<List<Order>> getPendingSyncOrders() =>
(select(orders)..where((o) => o.syncedAt.isNull())).get();
}
Repository with local-first reads
class OrderRepository {
OrderRepository(this._db, this._api);
final AppDatabase _db;
final ApiClient _api;
// Returns a stream — always shows local data immediately
Stream<List<Order>> watchOrders() => _db.watchOrders();
// Write locally first, fire-and-forget sync
Future<void> createOrder(OrderRequest request) async {
final localId = await _db.insertOrder(
OrdersCompanion.insert(
items: Value(jsonEncode(request.items)),
status: const Value('pending'),
syncedAt: const Value(null),
),
);
unawaited(_syncOrder(localId)); // Don't block UI
}
Future<void> _syncOrder(int localId) async {
try {
final order = await _db.getOrder(localId);
final remote = await _api.createOrder(order.toRequest());
await _db.markSynced(localId, remoteId: remote.id);
} catch (e) {
debugPrint('Sync failed for $localId: $e');
// Will retry on next connectivity change
}
}
}
Connectivity-aware background sync
class SyncService {
SyncService(this._repo, this._connectivity);
StreamSubscription? _sub;
void start() {
_sub = _connectivity.onConnectivityChanged.listen((result) {
if (result != ConnectivityResult.none) _syncAll();
});
_syncAll(); // Also try on startup
}
Future<void> _syncAll() async {
final pending = await _repo.getPendingSyncOrders();
for (final order in pending) {
await _repo.syncOrder(order);
}
}
void dispose() => _sub?.cancel();
}
Conflict resolution strategies
Last-write-wins: Compare updatedAt timestamps. Simple but can silently discard changes.
Server-wins: Server state always takes priority on conflict. Safe default for most apps.
Client-wins: Local changes always win. Appropriate for personal notes or settings.
Merge: Combine changes field-by-field. Required for collaborative editing.
Showing sync status in UI
StreamBuilder<Order>(
stream: repo.watchOrder(order.id),
builder: (_, snap) {
final isSynced = snap.data?.syncedAt != null;
return Row(
children: [
OrderTitle(order: order),
if (!isSynced)
const Icon(Icons.cloud_off, size: 16, color: Colors.orange),
],
);
},
)
Common pitfalls
No retry mechanism. A failed sync with no retry is eventual data loss. Queue failed syncs and retry them on the next connectivity change with exponential backoff.
Syncing on every keystroke. A search field that syncs on each character fires hundreds of requests. Debounce writes before syncing, or only sync on explicit user actions.
No database migration plan. As your schema evolves, local databases on user devices need migrations. Drift has a built-in migration system. Design migrations from day one — retrofitting them after shipping is painful.
Sign in to like, dislike, or report.