All notes
08Note8 min

Offline-first Flutter: keep the merge off the UI thread

  • Flutter
  • Dart
  • Offline-first
  • Architecture

An app that works in a basement is not an app with a retry button. How to structure local-first writes in Flutter, and why the sync pass belongs in an isolate.

Most apps described as offline-capable are online apps with a cache and an error state. They work offline in the sense that they do not crash. That is a different product from one used by an engineer in a substation who will not see a bar of signal for six hours and needs to complete forty jobs regardless.

The test is not whether the app survives losing connectivity. It is whether anyone using it can tell the difference.

The local database is the source of truth

The inversion that makes this work: the server is not authoritative during a session. The device is. Every write goes to local storage first and the UI reads only from there, so there is no code path where the interface is waiting on a network call to render what the user just did.

dart
// Drift — a reactive query. The UI rebuilds when the LOCAL table
// changes, and knows nothing about whether a sync is in flight.
Stream<List<Job>> watchAssignedJobs(String engineerId) =>
    (select(jobs)
          ..where((j) => j.assignee.equals(engineerId))
          ..where((j) => j.deletedAt.isNull())
          ..orderBy([(j) => OrderingTerm(expression: j.scheduledFor)]))
        .watch();

Drift over raw sqflite for one reason that matters more than the type safety: those streams. The sync engine writes to the same tables from a background isolate and every watching widget updates on its own. There is no manual invalidation and no cache to keep in step.

Writes are an outbox, not a queue of requests

Do not queue HTTP calls. Queue intent. A stored request is a snapshot of a decision made against state that may have moved on by the time it sends; a stored intent can be re-evaluated against whatever the server now says.

dart
// Every mutation is a row. The transaction is what makes the write
// and its outbox entry atomic — a crash between the two would leave
// a change that is on the device but will never reach the server.
Future<void> completeJob(String jobId, JobResult result) =>
    transaction(() async {
      await (update(jobs)..where((j) => j.id.equals(jobId)))
          .write(JobsCompanion(status: Value(JobStatus.done)));

      await into(outbox).insert(OutboxCompanion.insert(
        entity: 'job',
        entityId: jobId,
        op: 'complete',
        payload: jsonEncode(result.toJson()),
        createdAt: DateTime.now().toUtc(),
      ));
    });

Now the part everyone gets wrong

Signal returns. Four hundred queued edits flush, the server responds with server-side state for each, and the app has to reconcile all of it. Do that on the UI thread and the app freezes at exactly the moment the user is most likely to be watching it.

Dart is single-threaded per isolate. `async` does not buy you parallelism — an await point yields, but a long synchronous span of JSON decoding and diffing between awaits blocks the event loop and the frame scheduler with it.

dart
// compute() spawns an isolate, runs the function, returns the result.
// Anything heavier than a couple of frames' work belongs here.
final merged = await compute(_reconcile, ReconcileInput(
  local: pendingEdits,
  remote: serverState,
));

// Runs in the isolate. Must be a top-level or static function, and
// everything crossing the boundary must be sendable — no database
// handles, no open sockets, no closures over UI state.
List<Merge> _reconcile(ReconcileInput input) {
  // ...decode, diff, resolve. Hundreds of ms is fine out here.
}

The constraint on what can cross an isolate boundary is a design forcing function, and a useful one. It pushes you toward a merge function that is pure — inputs in, decisions out — which is also the shape that is straightforward to unit test without a device.

Conflicts are a product decision

Last-write-wins is a choice to silently discard someone's work, and it is almost never the right one for records people are accountable for. On the field-service app the merge produces three outcomes: apply, supersede, or escalate — and escalate surfaces both versions to a supervisor rather than picking.

  • Timestamp every edit on the device, in UTC, and never trust device clocks for ordering — use them for display and a server-assigned sequence for truth.
  • Store edits as an append-only log per entity. You cannot reconstruct what happened from a mutated row.
  • Decide up front which fields can merge automatically and which always escalate. That list is a conversation with the client, not an engineering call.

What to measure

Median sync time on reconnect, frames dropped during a flush, and the proportion of sessions that complete with no connectivity at all. The last one is the number that tells you whether offline-first was worth building — if it is close to zero, you built for a scenario that does not happen.

We build this way for clients too.

Start a project