NK

Search

Search pages, posts, and components

All posts
Mobile12 min read

Building Offline-First Flutter Applications

Treating the local database as the source of truth - write queues, sync strategies, conflict resolution, and the failure modes that only appear on a real train.

flutterdartoffline-firstsyncarchitecture

Most apps are built online-first with an offline afterthought bolted on: a connectivity check, a "no internet" screen, and a retry button. That covers the demo. It does not cover a lift, a tunnel, a rural clinic, or hotel wifi that resolves DNS but drops every request - which is where apps are actually used.

Offline-first inverts the assumption. The local database is the source of truth for the UI, the network is a background process that reconciles it with a server, and connectivity becomes an implementation detail rather than a gate on functionality. Every read hits local storage. Every write lands locally first and is replayed to the server when possible.

That is a bigger change than it sounds, because it makes synchronisation your problem rather than the network layer's. This post covers the model, the storage and queueing pieces, conflict resolution, and the failure modes that only show up on a moving train.

Local storage is the source of truth

The defining rule: the UI never awaits the network. It reads from a local database and reacts to changes there. A sync engine writes into that same database when the server has news.

This produces an app that feels instant, because it is - a list view reads from SQLite in single-digit milliseconds regardless of signal. It also removes an entire category of UI state. There is no "loading" spinner on the main list, because there is nothing to wait for.

What you gain in responsiveness you pay for in consistency work. The local copy can be stale, and two devices can edit the same record. Those problems do not exist online-first, and pretending they do not exist offline-first is how you end up with silent data loss.

Choosing the local database

Three realistic options in Flutter, and the choice matters more than usual because migrating storage engines mid-project is painful.

Drift (SQLite with a typed Dart API) is my default for anything relational. You get real SQL, joins, transactions, and - critically - watch() queries that emit a new result whenever the underlying tables change. That last feature is what makes reactive offline-first UI straightforward.

Isar is faster for simple object graphs and has a pleasant API, but you give up SQL. For heavily relational data that becomes a constraint you feel.

Hive is a key-value store. Fine for settings and small caches, wrong for a domain model with relationships. Reaching for it as a database is the most common early mistake.

// Drift: a query that re-emits whenever orders change
Stream<List<Order>> watchPendingOrders() =>
    (select(orders)..where((o) => o.syncState.equals('pending')))
.watch()
.map((rows) => rows.map((r) => r.toEntity()).toList());

The widget subscribes to that stream and never thinks about the network again.

Every row carries sync metadata

Domain fields are not enough. Each synced table needs bookkeeping columns:

class Orders extends Table {
  TextColumn get id => text()();                       // client-generated UUID
  TextColumn get payload => text()();
  DateTimeColumn get updatedAt => dateTime()();        // local edit time
  DateTimeColumn get serverUpdatedAt => dateTime().nullable()();
  TextColumn get syncState => text()();                // synced | pending | conflict
  IntColumn get retryCount => integer().withDefault(const Constant(0))();
  BoolColumn get deleted => boolean().withDefault(const Constant(false))();
}

Three of those deserve explanation.

Client-generated ids. A record created offline needs an identity before the server has seen it, or nothing can reference it. Generate a UUID on the client and let the server accept it. Server-assigned integer ids force you to rewrite every foreign key when the row finally syncs - a rewrite that is easy to get wrong and hard to test.

Soft deletes. A row deleted offline must remain locally so the deletion can be replayed. Hard-deleting means the sync engine has nothing to send, and the record reappears at the next pull.

Retry count. A write that fails forever needs to stop retrying and surface to the user, rather than looping until the battery dies.

The write queue is the heart of it

Reads are the easy half. Writes are where offline-first is won or lost, because a write made offline must survive an app kill, a reboot, and a week without signal.

Persist the intent, not the request

The naive approach queues HTTP requests. That breaks the moment your API changes, and it serialises implementation detail - headers, URLs, body shape - into durable storage.

Queue the intent instead: an operation type and its domain payload. The sync engine translates intent into a request at send time, so an API change is a change in one translator rather than a migration of every queued row.

class PendingOperation {
  final String id;
  final String entityId;
  final OperationType type;   // create | update | delete
  final Map<String, dynamic> payload;
  final DateTime createdAt;
  final int attempts;
}

Order matters, and so does collapsing

The queue must drain in causal order. Creating an order and then adding a line item cannot be sent in reverse, or the second call references something the server has never heard of. A simple monotonic sequence per entity is enough for most apps; full dependency graphs are rarely worth it.

Collapsing is the other half. A user who edits a title five times offline has queued five updates, and sending all five is wasteful and racy. Fold consecutive updates to the same entity into one before sending:

List<PendingOperation> collapse(List<PendingOperation> ops) {
  final byEntity = <String, PendingOperation>{};
  for (final op in ops) {
    final existing = byEntity[op.entityId];
    if (existing == null) {
      byEntity[op.entityId] = op;
    } else if (op.type == OperationType.delete) {
      // A delete supersedes everything queued before it.
      byEntity[op.entityId] = op;
    } else {
      byEntity[op.entityId] = existing.mergedWith(op);
    }
  }
  return byEntity.values.toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt));
}

A create followed by a delete cancels out entirely - never send either.

Retry with backoff, and a ceiling

Retrying immediately on failure is how you flatten a battery in a dead zone. Exponential backoff with jitter, capped, plus a hard attempt limit after which the operation is parked and surfaced:

Duration backoffFor(int attempt) {
  final seconds = math.min(300, math.pow(2, attempt).toInt());
  final jitter = math.Random().nextInt(1000);
  return Duration(seconds: seconds, milliseconds: jitter);
}

The jitter matters more than it looks: without it, every device that lost connectivity during the same outage retries in lockstep and stampedes your server the moment it returns.

Distinguish failure types, too. A 500 is worth retrying; a 422 validation error is not, and will fail identically forever. Retrying non-retryable errors is the most common bug in home-grown sync engines.

Sync strategy and conflict resolution

With reads local and writes queued, the remaining question is how the two copies converge.

Pull with a cursor, not a full refresh

Downloading everything on each sync is fine with a hundred rows and untenable with a hundred thousand. Ask the server for changes since a cursor - a timestamp or opaque token - and persist that cursor only after the batch is committed locally:

Future<void> pull() async {
  var cursor = await _meta.readCursor();
  while (true) {
    final page = await _api.changesSince(cursor, limit: 500);
    if (page.changes.isEmpty) break;
 
    await _db.transaction(() async {
      for (final change in page.changes) {
        await _applyRemote(change);
      }
      await _meta.writeCursor(page.nextCursor);
    });
 
    cursor = page.nextCursor;
  }
}

The transaction is load-bearing. Applying changes and advancing the cursor must be atomic, or an app kill mid-batch skips records permanently - a bug that shows up weeks later as mysteriously missing data.

Pick a conflict policy deliberately

Two devices edit the same record. Somebody's edit has to yield, and the only wrong answer is not deciding.

Last-write-wins is the default in most apps: compare timestamps, newest wins. Simple, and silently destroys the other edit. Acceptable for low-contention data like user preferences.

Field-level merge applies LWW per field rather than per record. Two people editing different fields of the same customer both keep their change. Noticeably better for form-shaped data, and only moderately harder.

Server-authoritative always discards the local version on conflict. The right call when the server enforces rules the client cannot - inventory, pricing, anything with money.

Ask the user - mark the row conflict and show both versions. The most respectful option and the most expensive; reserve it for content people author, like documents or notes.

Whatever you choose, be aware that device clocks are wrong. Users travel, change timezones, and set the date manually to skip a paywall. Prefer server timestamps for ordering, and treat local time as a hint.

Optimistic UI needs a rollback path

Applying a write locally and showing it immediately is the point. But the server can still reject it - validation, permissions, a stale precondition - and the UI must be able to walk it back.

Keep the pre-edit state with the queued operation so a rejection can restore it, and tell the user plainly. A change that silently disappears three minutes after they made it is far worse than an error at the time.

Building an offline-first Flutter application: the six pieces

Everything above, as a checklist. If one of these rows is missing from your app, that is where the data loss will come from.

PieceWhat it doesWhat I useThe mistake to avoid
Local databaseThe source of truth the UI reads from. Never awaits the network.Drift for relational data, Isar for simple object graphs.Using Hive as a database. It is a key-value store - fine for settings, wrong for a domain model.
CachingKeeps server data locally so reads are instant and work in a tunnel.Cursor-based pull into the same tables the UI already watches.A separate cache layer beside the database. Two stores means two truths that disagree.
Network detectionDecides when it is worth attempting a sync.connectivity_plus as a hint, plus the actual request result as the truth.Trusting "connected". Captive portals and hotel wifi report online and drop every request.
Syncing dataDrains queued writes upward, pulls remote changes down.Durable queue of intent, collapsed and ordered; pull by cursor inside one transaction.Queueing HTTP requests instead of intent, and advancing the cursor outside the transaction.
Conflict handlingDecides whose edit survives when two devices disagree.Field-level merge for form data; server-authoritative for money.Defaulting to last-write-wins without deciding. It silently destroys the other edit.
Offline/online statesTells the user what is happening without blocking them.A per-row syncState (synced / pending / conflict) surfaced as a subtle indicator.A blocking "no internet" screen. That is the online-first habit you are trying to remove.

The row people underestimate is network detection. It looks like a solved problem - one package, one boolean - and it is the one that produces the strangest bug reports, because "connected but nothing works" is a state most apps never model.

Key takeaways

  • The local database is the source of truth. The UI never awaits the network; reactive queries push changes into widgets.
  • Client-generate ids and soft-delete. Records created or deleted offline need identity and a replayable tombstone.
  • Queue intent, not HTTP requests, so an API change does not invalidate everything already queued.
  • Collapse and order the queue. Five edits to one field are one update; a create-then-delete is nothing at all.
  • Back off with jitter and a ceiling, and never retry errors that cannot succeed.
  • Commit pulled batches and the cursor in one transaction, or you will lose records to a mid-sync app kill.
  • Choose a conflict policy explicitly - LWW, field merge, server-wins, or user-resolved - and remember device clocks lie.

FAQ

Should I just use Firebase or PowerSync instead?

If they fit, yes - actually. Firestore's offline persistence and PowerSync's SQLite replication solve most of this, and hand-rolling sync is a real engineering commitment. Build it yourself when you need a custom conflict policy, an existing backend you cannot replace, or data residency rules those services do not satisfy.

How do I test any of this?

Fake the clock and the connectivity, not the database. Drive an in-memory SQLite instance through scripted scenarios: queue three writes offline, come online mid-drain, kill the app, resume. The bugs live in interleavings, and only scripted scenarios find them reliably.

What about large binary attachments?

Keep them out of the row. Store a local file path plus an upload state, and sync the file separately with resumable uploads. A 20 MB photo inside a sync payload turns a flaky connection into a permanently failing operation.

Does connectivity_plus tell me I am online?

It tells you a network interface exists, which is not the same thing. Captive portals and hotel wifi report connected and drop every request. Treat it as a cheap hint that it is worth attempting a sync, and let the actual request result be the truth.

How much does this slow down feature work?

Meaningfully, at first. Every synced entity needs metadata, a queue path, and a conflict decision. It amortises well - the second and third features reuse the engine - but budget for the first one taking two or three times as long.

Should sync run in an isolate?

For a large pull, yes: JSON decoding and bulk inserts on the UI isolate cause visible jank. Drift supports running on a background isolate, and it is easier to adopt at the start than to retrofit.

Conclusion

Offline-first is not a feature you add; it is an assumption you build on. The architecture is mostly bookkeeping - metadata on rows, a durable queue, a cursor, and a conflict policy - and none of it is individually difficult. What makes it hard is that the failure modes only appear in conditions you cannot reproduce at your desk: the partial sync, the clock skew, the app killed mid-drain, the network that lies about being connected.

Build the queue and the cursor properly on the first feature, and every feature after it inherits the work. Bolt them on later and you will be reconciling data loss reports from users who were on a train.

Read more

This pairs naturally with a layered codebase - the sync engine belongs in the data layer, invisible to your use cases and widgets. See Building Scalable Flutter Apps with Clean Architecture for where the pieces sit.