Posted on Oct 10
Offline-first in Flutter works when the UI only ever reads from the local Drift database, and every write goes into that database along with an outbox row in the same transaction. A background worker then sends the outbox to your API with idempotency keys. Last-write-wins settles conflicts for most fields, and explicit merge rules cover the few fields where a blind overwrite would throw away someone's work.
Below is a version of that design you can read in one sitting, plus the tests that catch failures you normally only see on a train with one bar of signal.
The moving parts
-
Drift tables hold the app data. Screens subscribe with
watch()queries, so they never wait on the network. -
An outbox table records each local change as an operation with a unique
opId. - A sync worker pushes operations in order, retries with backoff, and deletes an operation only after the server confirms it.
- A pull step fetches server changes and runs them through a merge function before writing them locally.
The rule that keeps this manageable is that the network layer never touches UI state. It writes to Drift, and Drift streams update the screens.
Schema: data plus an outbox
class Tasks extends Table {
TextColumn get id => text()();
TextColumn get title => text()();
TextColumn get notes => text().withDefault(const Constant(''))();
TextColumn get tags => text().withDefault(const Constant('[]'))(); // JSON list
IntColumn get updatedAt => integer()();
IntColumn get serverVersion => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {id};
}
class Outbox extends Table {
IntColumn get seq => integer().autoIncrement()();
TextColumn get opId => text().unique()();
TextColumn get entityId => text()();
TextColumn get payload => text()(); // only the fields that changed
IntColumn get attempts => integer().withDefault(const Constant(0))();
IntColumn get nextAttemptAt => integer().withDefault(const Constant(0))();
}
Store only the changed fields in the payload. That way you always know which fields the user actually touched, which is what makes field-level merging possible later.
The write path: always one transaction
Future<void> renameTask(String id, String title) {
return transaction(() async {
final now = clock.now().millisecondsSinceEpoch;
await (update(tasks)..where((t) => t.id.equals(id))).write(
TasksCompanion(title: Value(title), updatedAt: Value(now)),
);
await into(outbox).insert(OutboxCompanion.insert(
opId: uuid.v4(),
entityId: id,
payload: jsonEncode({'title': title, 'updatedAt': now}),
));
});
}
Without the transaction, an app killed between those two writes would show the user a change the server never receives. With it, either both rows exist or neither does.
The sync worker
class SyncWorker {
SyncWorker(this.db, this.api, {Clock? clock}) : clock = clock ?? const Clock();
final AppDb db;
final TaskApi api;
final Clock clock;
bool _running = false;
Future<void> drain() async {
if (_running) return; // single flight
_running = true;
try {
while (true) {
final op = await db.nextDueOp(clock.now().millisecondsSinceEpoch);
if (op == null) return;
switch (await api.push(op)) {
case PushOk(:final serverVersion):
await db.ackOp(op, serverVersion);
case PushRetry():
await db.backoff(op, clock.now());
return;
case PushRejected(:final reason):
await db.deadLetter(op, reason);
}
}
} finally {
_running = false;
}
}
}
The decisions built into this worker:
-
Single flight. Connectivity events, app resume and pull-to-refresh can all call
drain()at the same moment. Only one loop runs. -
Ack only after success.
ackOpdeletes the outbox row and saves the newserverVersionin one transaction. -
A retry stops the loop. A timeout or 5xx means the network is bad.
backoffsetsnextAttemptAtusing exponential delay plus jitter, capped at about a minute. - Rejections never block the queue. A 4xx validation error moves the operation to a dead-letter table the UI can show. Otherwise one bad record holds up every change after it.
-
Idempotency on the server. The
opIdgoes out as an idempotency key and the server records the keys it has processed. If a first attempt actually succeeded, the retry is not applied a second time.
Inside api.push, map TimeoutException, SocketException and 5xx responses to PushRetry. A 30 second timeout is a sensible starting point. A request with no timeout will eventually hang on a captive Wi-Fi portal.
Conflict resolution: LWW by default, rules where it hurts
Last-write-wins per field is fine for a status, a due date or a title. It breaks when two people both added something, or when losing typed text would make a user angry.
On pull, the client merges each incoming row against the fields that still have unsent local edits:
typedef FieldRule = Object? Function(Object? local, Object? remote);
final taskRules = <String, FieldRule>{
// Union for sets. Removals need tombstones or deleted tags come back.
'tags': (l, r) => {...(l as List), ...(r as List)}.toList(),
};
Map<String, Object?> mergeRow({
required Map<String, Object?> local,
required Map<String, Object?> remote,
required Set<String> pendingFields,
}) {
final merged = Map<String, Object?>.from(remote);
for (final field in pendingFields) {
final rule = taskRules[field] ?? ((l, r) => l); // pending local edit wins for now
merged[field] = rule(local[field], remote[field]);
}
return merged;
}
pendingFields is the union of the payload keys still waiting in the outbox for that entity. Fields with no pending edit take the server value. Fields with a pending edit keep the local value until the push lands, and then the server's own LWW decides.
Agree on a rule sheet like this before writing code:
- Status, dates, single values: LWW.
- Free text a person typed: LWW, but keep the losing version in a history or conflict field. Never drop it silently.
- Sets (tags, assignees): union, with tombstones for removals.
- Counters (stock, likes): send deltas, not absolute values.
-
Deletes: soft delete with a
deletedAtuntil the server confirms.
One warning: device clocks drift, and users change them. Let the server stamp the authoritative time when a change arrives, or use serverVersion as a compare-and-set token. Don't treat the phone's updatedAt as the final word.
Tests for flaky networks
Drift runs in memory with NativeDatabase.memory(), and a scripted fake API lets you replay bad network days on demand.
void main() {
late AppDb db;
setUp(() => db = AppDb(NativeDatabase.memory()));
tearDown(() => db.close());
test('timeout then success: same opId, outbox cleared', () async {
var now = DateTime(2026, 1, 1);
final api = FakeApi([PushRetry(), PushOk(serverVersion: 2)]);
final worker = SyncWorker(db, api, clock: Clock(() => now));
await db.seedTask('t1', 'Draft');
await db.renameTask('t1', 'Final');
await worker.drain();
expect(await db.pendingCount(), 1);
now = now.add(const Duration(minutes: 2));
await worker.drain();
expect(await db.pendingCount(), 0);
expect(api.sentOpIds, hasLength(2));
expect(api.sentOpIds.toSet(), hasLength(1));
});
test('pending local title survives a pull', () async {
await db.seedTask('t1', 'Draft');
await db.renameTask('t1', 'Mine');
await db.applyRemote({'id': 't1', 'title': 'Theirs', 'notes': 'from web', 'serverVersion': 5});
final task = await db.getTask('t1');
expect(task.title, 'Mine');
expect(task.notes, 'from web');
});
}
Add three more tests before you ship:
-
Lost acknowledgement: the server succeeded but the app died before
ackOp, so the server must deduplicate the retry. -
Parallel calls: three
drain()calls fired together should produce exactly one push. - Rejected operation: it lands in the dead-letter table while later operations still go through.
Then do a manual pass on a real device: turn on airplane mode in the middle of a push, force-kill the app during sync, and set the clock a day ahead.
If you are deciding how to build
Offline support is not something you can bolt on at the end of a project. The main cost drivers are how many entities sync, how many of them need custom merge rules, and whether the backend supports idempotency keys and versioning. Settle the per-field rule sheet with your product owner early. Changing conflict behavior after users already have data on their phones means running migrations on every device.
For the wider set of decisions around a Flutter build, the Flutter app development guide from Geminate Solutions is a good next read.
Top comments (0)
For further actions, you may consider blocking this person and/or reporting abuse
