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.
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.
| Option | Model | Reactive queries | Transactions | Best when |
|---|---|---|---|---|
| Drift | Relational (SQLite) | Yes (watch()) | Strong, SQL | Relational data, complex queries, migrations matter |
| Isar | NoSQL object store | Yes (watch()) | Yes | Object graphs, very high read throughput, simple schema |
| sqflite | Raw SQLite | No (manual) | Yes | You want full control and don't mind wiring reactivity yourself |
| Hive | Key-value | Limited | No | Simple 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:
| Strategy | Data loss risk | Complexity | Use it for |
|---|---|---|---|
| Last-write-wins | High (whole record) | Low | Single-owner records, settings, drafts |
| Field-level merge | Low | Medium | Forms edited from multiple devices |
| CRDT | None (converges) | High | Real-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:
- Read from the local DB via reactive streams — the UI never waits.
- Write locally and enqueue to the outbox in one transaction.
- Push the outbox in
seqorder with backoff, idempotency, and dead-lettering. - Pull deltas since the last cursor and reconcile through your conflict resolver.
- 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
Jun 19, 2026
Clean Architecture in Flutter with BLoC: A Practical Guide
A hands-on guide to splitting a Flutter app into domain, data, and presentation layers with BLoC — a full feature: entity, use case, repository with DTO mapping, and a Cubit.
Jan 20, 2025
Automating Flutter Releases with GitHub Actions
Turning the stressful release-day ritual into a calm, repeatable pipeline that ships signed builds to both stores.
Nov 3, 2024
Building Offline-First Apps with Hive and BLoC
A pattern for caching writes locally and reconciling with the server so the app stays usable with no connection.