Every Flutter engagement we take on eventually hits the same question: what happens when the network doesn't? A field service app in a basement, a delivery app in a lift, a retail app on venue Wi-Fi that drops every ninety seconds. Teams usually start with "we'll add a cache later", then discover that offline is not a cache — it's an architecture.
This tutorial builds the architecture: a local database as the single source of truth, a durable outbox of pending writes, a sync engine with backoff, and a conflict resolution policy you can explain to a product owner. The stack is Drift 2.x on Flutter 3.4x, but the shape transfers to Isar, sqlite_async, or PowerSync.
The one rule: the UI never talks to the network
The mistake that costs the most rework is letting widgets await HTTP calls and fall back to a cache on failure. You end up with two code paths, two loading states, and two sets of bugs.
Invert it. The UI reads from and writes to the local database only. A separate sync engine moves data between the local database and the server. Reads are streams from SQLite, so the UI updates automatically whenever sync writes something new.
Widgets -> Repository -> Drift (SQLite) <- source of truth
^
| outbox + pull
Sync engine <-> API
Every screen renders instantly, offline or online, because there is nothing to wait for.
Dependencies
dependencies:
drift: ^2.20.0
drift_flutter: ^0.2.0
connectivity_plus: ^6.0.5
uuid: ^4.5.0
http: ^1.2.2
workmanager: ^0.5.2 # optional: background sync
dev_dependencies:
drift_dev: ^2.20.0
build_runner: ^2.4.13
Schema: domain tables plus an outbox
Two things distinguish an offline-first schema from a normal one. Primary keys are client-generated UUIDs, not server auto-increment integers — otherwise the app cannot create a record offline and reference it from another record. And every row carries sync bookkeeping.
// lib/data/tables.dart
import 'package:drift/drift.dart';
class WorkOrders extends Table {
TextColumn get id => text()(); // client-generated UUID v7
TextColumn get title => text()();
TextColumn get status => text().withDefault(const Constant('open'))();
TextColumn get notes => text().withDefault(const Constant(''))();
DateTimeColumn get updatedAt => dateTime()(); // client clock, for LWW
TextColumn get serverVersion => text().nullable()(); // ETag / rev from server
BoolColumn get dirty => boolean().withDefault(const Constant(false))();
BoolColumn get deleted => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
}
/// Durable queue of writes that still need to reach the server.
class OutboxEntries extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get entity => text()(); // 'work_order'
TextColumn get entityId => text()();
TextColumn get op => text()(); // 'upsert' | 'delete'
TextColumn get payload => text()(); // JSON snapshot
DateTimeColumn get createdAt => dateTime()();
IntColumn get attempts => integer().withDefault(const Constant(0))();
DateTimeColumn get nextAttemptAt => dateTime().nullable()();
TextColumn get lastError => text().nullable()();
}
/// Per-entity cursor for incremental pulls.
class SyncState extends Table {
TextColumn get entity => text()();
DateTimeColumn get lastPulledAt => dateTime().nullable()();
TextColumn get cursor => text().nullable()();
@override
Set<Column> get primaryKey => {entity};
}
deleted is a tombstone rather than a real delete: a row removed offline must still be reported to the server, and a row deleted on the server must not reappear on the next pull.
Writes: local commit and outbox entry in one transaction
This is the whole trick. If the local write and the outbox entry are not in the same transaction, a crash between them either loses the change or ships a change the user never sees.
// lib/data/work_order_repository.dart
class WorkOrderRepository {
WorkOrderRepository(this._db, this._sync);
final AppDatabase _db;
final SyncEngine _sync;
Stream<List<WorkOrder>> watchOpen() =>
(_db.select(_db.workOrders)
..where((t) => t.deleted.equals(false) & t.status.equals('open'))
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
.watch();
Future<void> save(WorkOrder order) async {
final now = DateTime.now().toUtc();
final next = order.copyWith(updatedAt: now, dirty: true);
await _db.transaction(() async {
await _db.into(_db.workOrders).insertOnConflictUpdate(next);
await _db.into(_db.outboxEntries).insert(
OutboxEntriesCompanion.insert(
entity: 'work_order',
entityId: next.id,
op: 'upsert',
payload: jsonEncode(next.toJson()),
createdAt: now,
),
);
});
_sync.nudge(); // fire-and-forget; success does not block the UI
}
}
Note what save does not do: it does not return a network result, and the caller does not show a spinner. The row is already on screen via the watch() stream before the sync engine wakes up.
Coalescing the outbox
A user who edits the same note six times offline should produce one request, not six. Coalesce pending upserts per entity before sending:
Future<List<OutboxEntry>> _pendingBatch({int limit = 50}) async {
final now = DateTime.now().toUtc();
final rows = await (_db.select(_db.outboxEntries)
..where((t) => t.nextAttemptAt.isSmallerOrEqualValue(now) | t.nextAttemptAt.isNull())
..orderBy([(t) => OrderingTerm.asc(t.id)])
..limit(limit * 4))
.get();
// Keep only the newest entry per (entity, entityId); delete wins over upsert.
final byKey = <String, OutboxEntry>{};
final superseded = <int>[];
for (final row in rows) {
final key = '${row.entity}:${row.entityId}';
final existing = byKey[key];
if (existing != null) superseded.add(existing.id);
byKey[key] = (existing?.op == 'delete') ? existing! : row;
}
if (superseded.isNotEmpty) {
await (_db.delete(_db.outboxEntries)..where((t) => t.id.isIn(superseded))).go();
}
return byKey.values.take(limit).toList();
}
The sync engine: push, then pull
Push first. Pulling before pushing means the server's older copy overwrites the user's unsent edit — the single most common offline-first data loss bug.
// lib/sync/sync_engine.dart
class SyncEngine {
SyncEngine(this._db, this._api, this._connectivity);
final AppDatabase _db;
final Api _api;
final Connectivity _connectivity;
bool _running = false;
bool _requeued = false;
Timer? _retryTimer;
void start() {
_connectivity.onConnectivityChanged.listen((result) {
if (!result.contains(ConnectivityResult.none)) nudge();
});
nudge();
}
/// Safe to call from anywhere, as often as you like.
Future<void> nudge() async {
if (_running) { _requeued = true; return; }
_running = true;
try {
do {
_requeued = false;
await _pushOutbox();
await _pullChanges('work_order');
} while (_requeued);
} on SocketException {
_scheduleRetry(); // offline: wait for connectivity or backoff
} finally {
_running = false;
}
}
Future<void> _pushOutbox() async {
for (final entry in await _pendingBatch()) {
try {
final result = await _api.push(entry);
await _db.transaction(() async {
await (_db.delete(_db.outboxEntries)..where((t) => t.id.equals(entry.id))).go();
await _applyServerEcho(result); // stores serverVersion, clears dirty
});
} on ConflictException catch (e) {
await _resolveConflict(entry, e.serverRow);
} on ApiException catch (e) when (e.statusCode >= 400 && e.statusCode < 500) {
// Permanent: the payload will never be accepted. Park it, don't loop.
await _park(entry, e.message);
} catch (e) {
await _backoff(entry, e.toString());
rethrow; // stop the batch; the next nudge picks up where we left off
}
}
}
}
Three failure classes, three behaviours — and mixing them up is what produces the infamous "app hammers the API 400 times in a tunnel" bug report:
| Failure | Example | Behaviour |
|---|---|---|
| Transient | timeout, 502, no route to host | exponential backoff, retry forever |
| Conflict | 409 with server row | resolve, then retry once |
| Permanent | 400 validation, 403 | park the entry, surface it in the UI |
Future<void> _backoff(OutboxEntry entry, String error) async {
final attempts = entry.attempts + 1;
final delaySeconds = min(300, pow(2, attempts).toInt()) + Random().nextInt(5); // + jitter
await (_db.update(_db.outboxEntries)..where((t) => t.id.equals(entry.id))).write(
OutboxEntriesCompanion(
attempts: Value(attempts),
lastError: Value(error),
nextAttemptAt: Value(DateTime.now().toUtc().add(Duration(seconds: delaySeconds))),
),
);
}
The jitter is not decoration. Without it, every device that lost connectivity at the same moment retries at the same moment.
Incremental pull
Pull only what changed since the last cursor, and apply it in one transaction so the UI never renders a half-synced list.
Future<void> _pullChanges(String entity) async {
final state = await _db.syncStateFor(entity);
final page = await _api.changesSince(entity, cursor: state?.cursor);
await _db.transaction(() async {
for (final remote in page.rows) {
final local = await _db.workOrderById(remote.id);
if (local != null && local.dirty) continue; // don't clobber unsent local edits
await _db.into(_db.workOrders).insertOnConflictUpdate(remote.toRow());
}
for (final id in page.deletedIds) {
await (_db.update(_db.workOrders)..where((t) => t.id.equals(id)))
.write(const WorkOrdersCompanion(deleted: Value(true)));
}
await _db.setSyncCursor(entity, page.nextCursor, DateTime.now().toUtc());
});
}
Conflict resolution: pick a policy, write it down
There is no generic correct answer, only a decision the business has to make. Three policies cover most consulting work:
Last-write-wins on a server timestamp. Simplest, and fine for single-user-per-record data such as a technician's own job notes. Never use the device clock as the arbiter — phones with wrong clocks silently win or lose every conflict. Send the client timestamp for ordering hints, but let the server decide.
Field-level merge. Send only changed fields (a patch, not a snapshot) and let the server merge non-overlapping fields. Two users editing status and notes on the same record both keep their change. This costs a dirtyFields set on each row and pays for itself in every multi-user app.
Explicit user resolution. Keep both versions and ask. Only worth building where the data is expensive — pricing, medical, legal. Model it as a real state, not a dialog:
Future<void> _resolveConflict(OutboxEntry entry, ServerRow server) async {
switch (conflictPolicy) {
case ConflictPolicy.serverWins:
await _db.into(_db.workOrders).insertOnConflictUpdate(server.toRow(dirty: false));
await _dropEntry(entry);
case ConflictPolicy.clientWins:
final retry = jsonDecode(entry.payload) as Map<String, dynamic>
..['server_version'] = server.version; // rebase onto the current version
await _updatePayload(entry, retry);
case ConflictPolicy.askUser:
await _db.into(_db.conflicts).insert(ConflictsCompanion.insert(
entity: entry.entity, entityId: entry.entityId,
localPayload: entry.payload, remotePayload: jsonEncode(server.toJson()),
));
await _dropEntry(entry);
}
}
The optimistic-concurrency handshake behind this is plain HTTP: the client sends If-Match: <serverVersion>, the server returns 409 plus the current row if the version moved. No custom protocol required.
Telling the user the truth
Offline-first apps fail badly when they lie. Users tolerate "not sent yet"; they do not tolerate discovering a week later that nothing saved. Surface three signals:
class SyncBadge extends StatelessWidget {
const SyncBadge({super.key, required this.status});
final SyncStatus status;
@override
Widget build(BuildContext context) => switch (status) {
SyncStatus(pending: 0, parked: 0) => const SizedBox.shrink(),
SyncStatus(parked: final p) when p > 0 =>
_Chip(icon: Icons.error_outline, label: '$p change${p == 1 ? '' : 's'} need attention'),
SyncStatus(pending: final n) =>
_Chip(icon: Icons.cloud_upload_outlined, label: '$n pending'),
};
}
Per-row state matters too: a subtle "pending" mark on the item the user just edited beats a global spinner, because it survives navigation and tells them exactly which record is unsent. Drive it from dirty, which you already have.
Background sync
Foreground sync covers most cases. When work must leave the device even if the app is never reopened, register a periodic task — and remember it runs in a separate isolate, so it must open its own database connection and cannot touch your app's providers.
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
final db = AppDatabase(); // fresh connection in this isolate
try {
await SyncEngine(db, Api.fromEnv(), Connectivity()).nudge();
return true; // false schedules an OS-managed retry
} finally {
await db.close();
}
});
}
On iOS, background execution is opportunistic — treat it as a bonus, never as the guarantee. The guarantee is the outbox.
Testing the parts that actually break
Offline bugs hide in sequences, not in single calls. Three tests catch most of them:
test('pending pull does not clobber an unsent local edit', () async {
await repo.save(order.copyWith(notes: 'edited offline'));
api.enqueuePull([order.copyWith(notes: 'stale server copy')]);
await sync.nudge();
expect((await repo.byId(order.id)).notes, 'edited offline');
});
test('outbox survives a cold restart', () async {
api.offline = true;
await repo.save(order);
final reopened = AppDatabase.from(sameFile);
expect(await reopened.outboxCount(), 1);
});
test('permanent 400 parks instead of looping', () async {
api.failWith(400);
await sync.nudge();
await sync.nudge();
expect(api.pushCount, 1);
});
Drift's in-memory executor makes these fast enough to run on every commit. Pair them with an integration test that toggles the connectivity mock mid-flow — that is where the coalescing logic earns its keep.
A rollout order that works
When we retrofit offline support into an existing Flutter app, we sequence it like this:
- Move reads onto local streams first, with a naive fill-from-API. Nothing breaks, and the UI stops flickering.
- Add client-generated IDs. This is the most invasive change; do it while the surface area is small.
- Add the outbox for one write path — usually the highest-value form — and ship it.
- Add incremental pull with cursors, replacing full refetches.
- Add conflict handling last, once you can see real conflict rates in telemetry.
Teams that try to do all five at once spend a quarter on it. Teams that ship step by step have something useful in a fortnight.
Should you build it or buy it?
If your sync needs are a handful of tables with one writer per record, the code above is a few hundred lines you fully control. If you need multi-table transactional sync, real-time updates, and row-level access rules, evaluate PowerSync, Electric SQL, or Firestore's built-in offline persistence before writing your own. The decision hinges on one question: is your sync logic a differentiator, or a tax? Consulting answer, honestly given: for most teams it is a tax — but the tax is far cheaper to pay early than to retrofit after launch.
Building or rescuing an offline-first Flutter app? Get in touch — our Flutter consultants have shipped local-first sync in field service, logistics, and retail apps, and can review your data layer before it becomes a rewrite.