Skip to content
FlutterArchitectureOffline-First

Offline-First Flutter: Syncing Local and Remote Data Reliably

A practical engineering guide to offline-first Flutter: a local source of truth, a durable outbox sync queue, conflict resolution (last-write-wins vs. merge), and real failure handling.

By Bimal Khatri·9 min read·Jun 10, 2026·Updated Jun 21, 2026

Building an offline-first Flutter app is less about caching the network and more about flipping the dependency: the local database becomes your source of truth, and the server becomes a replica you reconcile with later. I've shipped this pattern across several production iOS and Android apps, and the ones that treated the network as optional from day one were dramatically more reliable than the ones that bolted on caching after launch.

This is a practical, example-driven guide for intermediate Flutter developers. We'll cover the architecture, picking a Flutter local database, designing a durable sync queue, choosing a conflict resolution strategy, and — the part most tutorials skip — handling the failures that actually happen in the field.

What "offline-first" really means

The phrase gets thrown around loosely, so let me be precise. An offline-first app has three non-negotiable properties:

  • The UI never blocks on the network. Every read and write hits local storage and returns immediately.
  • Writes are durable before they're sent. A user's edit survives an app kill, a crash, or a dead connection because it's persisted locally first.
  • Sync is a background reconciliation process, not part of the user's interaction loop.

The mental model I use: the user only ever talks to the local store. A separate sync engine watches a queue of pending changes and negotiates with the server whenever it can. The two are decoupled. If you remember nothing else from this article, remember that the write path and the sync path must not be the same code path.

UI  ──►  Local DB (source of truth)  ──►  Sync queue  ──►  Remote API
 ▲            │                                              │
 └── streams ─┘                            reconcile ◄───────┘

Choosing a Flutter local database

Your local store is the foundation, so pick deliberately. For an offline-first design you want reactive queries (so the UI rebuilds when local data changes) and real transactions (so a write and its queue entry commit atomically). The two I reach for are Drift and Isar.

OptionModelReactive queriesTransactionsBest when
DriftRelational (SQLite)Yes (watch())Strong, SQLRelational data, complex queries, migrations matter
IsarNoSQL object storeYes (watch())YesObject graphs, very high read throughput, simple schema
sqfliteRaw SQLiteNo (manual)YesYou want full control and don't mind wiring reactivity yourself
HiveKey-valueLimitedNoSimple settings/blobs, not a sync backbone

For anything with relationships and a sync queue, I default to Drift: SQL transactions let me write the row and enqueue the mutation in one atomic step, and watch() gives me a Stream the UI subscribes to. If your data is a big object graph and you care more about raw speed than joins, Isar is excellent. Avoid Hive as a sync backbone — its lack of real transactions will bite you.

Here's a minimal Drift schema with the two tables every offline-first app needs — the domain table and an outbox for pending mutations:

import 'package:drift/drift.dart';

class Todos extends Table {
  TextColumn get id => text()(); // client-generated UUID
  TextColumn get title => text()();
  BoolColumn get done => boolean().withDefault(const Constant(false))();
  IntColumn get updatedAt => integer()(); // epoch millis, our version clock
  BoolColumn get deleted => boolean().withDefault(const Constant(false))();

  @override
  Set<Column> get primaryKey => {id};
}

class Outbox extends Table {
  IntColumn get seq => integer().autoIncrement()();
  TextColumn get entity => text()();    // e.g. 'todo'
  TextColumn get entityId => text()();
  TextColumn get op => text()();        // 'upsert' | 'delete'
  TextColumn get payload => text()();   // JSON snapshot
  IntColumn get attempts => integer().withDefault(const Constant(0))();
}

Two details matter here. First, IDs are generated on the client (a UUID), not the server. This lets a user create records offline that already have stable identity. Second, updatedAt doubles as a logical version clock for conflict detection later.

Designing the sync queue (the outbox pattern)

The outbox pattern is the heart of a reliable sync engine. Instead of firing an HTTP request when the user edits something, you write the change to local storage and append a record to the outbox table — atomically, in one transaction. A background worker drains the outbox.

The atomicity is the whole point. If you write the row but the app dies before you enqueue the sync job, the server never hears about it. A single transaction makes "data changed" and "needs sync" inseparable.

Future<void> upsertTodo(Todo todo) async {
  final next = todo.copyWith(updatedAt: DateTime.now().millisecondsSinceEpoch);
  await db.transaction(() async {
    // toCompanion(true) maps nulls to Value.absent() rather than explicit null.
    await db.into(db.todos).insertOnConflictUpdate(next.toCompanion(true));
    await db.into(db.outbox).insert(OutboxCompanion.insert(
      entity: 'todo',
      entityId: next.id,
      op: 'upsert',
      payload: jsonEncode(next.toJson()),
    ));
  });
}

The UI calls upsertTodo, the local watch() stream emits, and the screen updates instantly — no network involved. Now the draining side. Process the outbox in order, oldest seq first, so causally dependent changes (create then update) reach the server in sequence:

Future<void> drainOutbox() async {
  final pending = await (db.select(db.outbox)
        ..orderBy([(o) => OrderingTerm.asc(o.seq)])
        ..limit(50))
      .get();

  for (final item in pending) {
    try {
      await _push(item);
      await (db.delete(db.outbox)..where((o) => o.seq.equals(item.seq))).go();
    } on ConflictException catch (e) {
      await _resolveConflict(item, e.serverRecord);
      await (db.delete(db.outbox)..where((o) => o.seq.equals(item.seq))).go();
    } on RetryableException {
      await (db.update(db.outbox)..where((o) => o.seq.equals(item.seq)))
          .write(OutboxCompanion(attempts: Value(item.attempts + 1)));
      break; // stop the batch; preserve ordering, retry later
    }
  }
}

Notice the three outcomes: success removes the entry, a conflict resolves and removes it, and a retryable failure bumps the attempt counter and stops the batch so we don't reorder later changes.

Conflict resolution: last-write-wins vs. merge

The hard part of offline-first Flutter is what happens when the same record was edited in two places. There's no universally correct answer — only trade-offs you choose deliberately per entity.

Last-write-wins (LWW)

The simplest strategy: each record carries a version/timestamp, and the most recent write overwrites the older one. It's easy to reason about and stateless. The cost is silent data loss — if two people edited different fields, one set of edits vanishes.

Todo resolveLww(Todo local, Todo remote) {
  return local.updatedAt >= remote.updatedAt ? local : remote;
}

LWW is the right call for low-contention, single-user-per-record data: a user's own settings, a draft only they edit, a "last read position." Use a monotonic clock you control (server-assigned timestamps or a logical counter), because device clocks lie and skew.

Field-level merge

When losing edits is unacceptable, merge at the field level. You compare the local version, the remote version, and ideally a common ancestor (the value at last sync) to detect which side actually changed each field. If only one side touched a field, take that side; if both changed the same field, you have a true conflict to escalate.

Todo merge({required Todo base, required Todo local, required Todo remote}) {
  // Three-way merge for a single field: returns the winning value.
  String pick(String b, String l, String r) {
    if (l == r) return l; // both sides agree
    if (l == b) return r; // only remote changed
    if (r == b) return l; // only local changed
    return r;             // both changed: policy — prefer remote, or flag it
  }

  final mergedDone = local.done == remote.done
      ? local.done
      : (local.done == base.done ? remote.done : local.done);

  return local.copyWith(
    title: pick(base.title, local.title, remote.title),
    done: mergedDone,
  );
}

For collaborative, structured data where even field merges aren't enough, CRDTs (conflict-free replicated data types) let independent replicas converge automatically without a central referee. They're powerful but add real complexity — reach for them only when concurrent editing of shared documents is a core feature, not before.

Here's how I choose:

StrategyData loss riskComplexityUse it for
Last-write-winsHigh (whole record)LowSingle-owner records, settings, drafts
Field-level mergeLowMediumForms edited from multiple devices
CRDTNone (converges)HighReal-time collaborative documents

Handling failures and partial sync

A sync engine that only works on a perfect connection isn't offline-first — it's an online app in denial. Plan for the messy reality.

Exponential backoff with jitter. Don't hammer a failing server. Back off geometrically and add randomness so a fleet of devices doesn't reconnect in a thundering herd:

import 'dart:math' as math;

Duration backoff(int attempt) {
  // Cap the base at 30s; everything stays int so Duration is happy.
  final base = math.min(30000, 500 * (1 << attempt));
  final jitter = math.Random().nextInt((base ~/ 2) + 1);
  return Duration(milliseconds: base + jitter);
}

Idempotent writes. A request can succeed on the server but fail before the client sees the 200, so the client retries. Send a client-generated request key (your outbox entityId plus a content hash works) and have the server deduplicate. With idempotency, "did this actually go through?" stops being a question you have to answer.

Poison messages. A mutation that fails forever — a deleted parent, a validation the server rejects — will block the queue if you let it. Cap attempts, move the entry to a dead-letter table, surface it, and keep draining the rest:

if (item.attempts >= 5) {
  await _moveToDeadLetter(item);
  await (db.delete(db.outbox)..where((o) => o.seq.equals(item.seq))).go();
  continue;
}

Trigger sync on the right events. Drain the outbox on app start, when connectivity_plus reports a connection returning, when the app comes back to the foreground, and after every local write (debounced). Treat connectivity as a hint, not a guarantee — a "connected" Wi-Fi with no real internet is common, so the real test is whether a request succeeds.

Pull as well as push. Everything above covers pushing local changes up. You also need to pull remote changes down — ideally a delta endpoint that returns records changed since a stored cursor (updatedSince), then apply each through the same conflict resolver before writing locally. One reconciliation path, used in both directions, keeps the logic honest.

Putting it together: a sync lifecycle

A clean offline-first Flutter app ends up with a small, predictable loop:

  1. Read from the local DB via reactive streams — the UI never waits.
  2. Write locally and enqueue to the outbox in one transaction.
  3. Push the outbox in seq order with backoff, idempotency, and dead-lettering.
  4. Pull deltas since the last cursor and reconcile through your conflict resolver.
  5. Resolve conflicts per entity — LWW for single-owner data, merge for shared.

Keep each stage independent and testable. The biggest reliability win in my experience isn't a clever algorithm — it's the discipline of making the local store authoritative and never letting the network leak into the write path.

Wrapping up

Offline-first isn't a feature you sprinkle on; it's an architecture: a local source of truth, a transactional outbox, a deliberate conflict policy, and failure handling that assumes the network will betray you. Get those four right and your app feels instant on a subway and bulletproof on flaky hotel Wi-Fi.

If you're building something like this and want a second pair of hands, work with me or get in touch — I'm always happy to talk shop about sync engines.

More writing

Keep reading