Offline-First Flutter Apps with Local Sync
Offline-first is a shift in where truth lives. Instead of the UI calling the network and waiting, the local database becomes the source of truth: every read and write hits local storage instantly, and a background process syncs with the server whenever the connection allows. The network stops being a prerequisite for the app working and becomes an enhancement. For anything used on the move — field service, logistics, an EV app in a parking garage with one bar of signal — this is the difference between an app people trust and one they curse.
I have built this pattern in Flutter more than once. Here is an architecture that holds up, using Drift for local storage and an outbox for reliable sync.
The core idea: UI never waits for the network
Draw the boundary clearly. The UI talks only to the local store. It never awaits an HTTP call to show data or to accept an edit.
┌────────────┐ watch/query ┌──────────────┐ sync ┌──────────┐
│ Flutter │ ◀────────────── │ Drift (SQLite)│ ◀──────▶ │ Server │
│ UI/State │ write locally │ = source of │ outbox │ API │
└────────────┘ ──────────────▶ │ truth │ queue └──────────┘
└──────────────┘
A user taps "save." You write to Drift, the UI's reactive stream updates immediately, and the change lands in an outbox table to be pushed later. If the app is offline, nothing about the experience changes — the write succeeded locally. When connectivity returns, the sync engine drains the outbox.
Why Drift as the local store
I default to Drift (typed SQLite for Dart) for sync-heavy apps for three reasons:
- Reactive queries. Drift streams query results, so the UI rebuilds automatically when local data changes — exactly what you want when both user edits and incoming sync mutate the same tables.
- Real SQL. Reconciliation needs joins, upserts, and conditional updates. Key-value stores like Hive make that awkward; SQL makes it natural.
- Typed and testable. Compile-time-checked queries and easy in-memory databases for tests.
// A watchable query — UI rebuilds when rows change, from any source.
Stream<List<Charger>> watchFavorites() =>
(select(chargers)..where((c) => c.isFavorite.equals(true)))
.watch();
The outbox: reliable writes that survive restarts
The piece people skip and regret is the outbox. Every local mutation that must reach the server gets a row: what changed, an operation type, a client-generated id, and a status. Sync reads pending rows, pushes them, and marks them done. Because the outbox is persisted in SQLite, an app kill mid-sync loses nothing.
@DataClassName('OutboxEntry')
class Outbox extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get entityType => text()(); // 'charging_session'
TextColumn get opType => text()(); // create/update/delete
TextColumn get payload => text()(); // JSON
TextColumn get idempotencyKey => text()(); // UUID, generated locally
DateTimeColumn get createdAt => dateTime()();
IntColumn get attempts => integer().withDefault(const Constant(0))();
}
The idempotencyKey is non-negotiable. Retries happen — a request times out, you push again, the server may have already processed the first attempt. A key the server dedupes on turns "double submit" into a no-op. This is the same discipline I leaned on for idempotency in the EV platform: make retries safe by construction, not by luck.
Draining the outbox on reconnect
Wire sync to connectivity changes and app lifecycle. On reconnect, drain the outbox in order, with exponential backoff on failure:
Future<void> drainOutbox() async {
final pending = await (db.select(db.outbox)
..orderBy([(o) => OrderingTerm.asc(o.createdAt)]))
.get();
for (final entry in pending) {
try {
await api.push(entry); // sends idempotencyKey header
await db.delete(db.outbox).delete(entry);
} on ApiException catch (e) {
if (e.isPermanent) {
await _moveToDeadLetter(entry); // don't retry forever
} else {
await _bumpAttempts(entry); // backoff, try later
break; // preserve ordering
}
}
}
}
Two details that matter: preserve ordering so a create is not pushed after its dependent update, and have a dead-letter path so a permanently failing item does not block the whole queue.
Conflict resolution: match the strategy to the data
There is no universal answer, so decide per data type:
| Data type | Strategy |
|---|---|
| User profile fields, settings | Last-write-wins on server timestamp |
| Independent records (new sessions) | No conflict — both keep, dedupe by id |
| Counters / balances | Server-authoritative; client requests deltas |
| Collaborative documents | Field-level merge or CRDTs |
Last-write-wins is fine for the majority of fields and is cheap: attach a version or updatedAt, and the later write wins. Reach for per-field merging or CRDTs only for genuinely collaborative data — they solve a real problem but add real complexity, so do not pay for them where LWW suffices.
Pulling from the server, I fetch changes since a stored cursor (a since timestamp or server change token) and upsert into Drift, letting the reactive queries update the UI. Deletes are usually soft (a deletedAt column) so a delete on one device propagates cleanly instead of a row silently reappearing.
Making it feel honest
Offline-first is as much UX as data. Show sync state truthfully: a subtle "pending" marker on unsynced items, a "last synced 3 min ago" line, and never a spinner that blocks input. The pattern I use for real-time apps applies here too — model connectivity as state the UI reacts to, so the app can say "saved, will sync" instead of pretending everything is instantly on the server.
Done right, offline-first makes an app feel faster even when online, because reads and writes never wait on a round trip. The work is in the plumbing — a local source of truth, a durable outbox with idempotency keys, and conflict rules chosen per data type. Get those three right and flaky networks become a non-event. Want a review of your sync design? Let's talk.
Resources
- Drift (SQLite for Dart) documentation
- Offline-first patterns on web.dev
- Transactional outbox pattern (microservices.io)
- CRDTs explained
- Flutter connectivity_plus package
- Martin Kleppmann on local-first software
Frequently asked questions
What does offline-first mean in a Flutter app?
Offline-first means the local database is the source of truth for the UI. Reads and writes hit local storage instantly, and a background sync process reconciles with the server when connectivity allows. The network becomes an enhancement, not a requirement.
Which local database should I use for offline-first Flutter?
Drift (SQLite) is my default because it gives typed queries, reactive streams the UI can watch, and full SQL for the reconciliation logic sync needs. Isar and Hive are lighter options, but sync-heavy apps benefit from SQL's querying power.
How do you handle sync conflicts in an offline-first app?
Pick a strategy per data type. Last-write-wins with server timestamps is simplest and fine for most fields; use per-field merging or CRDTs for collaborative data. Always keep an outbox of local changes with idempotency keys so retries never duplicate writes.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →