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.
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.
Glossary
Everything this post uses, defined before it is used. Skip it if the terms are already familiar, or come back when one of them trips you up.
The one sentence the rest of this post expands: the app reads and writes to a database on the phone, and a background process reconciles that database with the server whenever it can.
Terms
| Term | Meaning |
|---|---|
| Offline-first | Building so the app works with no network at all, and treats connectivity as a bonus. |
| Source of truth | The one copy of the data everything else is derived from. Here, the local database. |
| Local database | Storage on the device itself. Drift, Isar and Hive are the common Flutter choices. |
| Reactive query | A query that stays subscribed and emits a fresh result whenever the underlying rows change. |
| Sync engine | The background code that pushes local changes up and pulls remote changes down. |
| Write queue | A durable list of changes made locally that have not reached the server yet. |
| Intent | What the user meant, like "delete order 42", stored instead of the HTTP request that would express it. |
| Optimistic UI | Showing a change as done immediately, before the server has confirmed it. |
| Rollback | Undoing an optimistic change after the server rejects it. |
| Client-generated id | An identifier the device invents so a record has identity before the server sees it. |
| Soft delete | Marking a row deleted instead of removing it, so the deletion can still be sent. |
| Tombstone | The marker a soft delete leaves behind. |
| Cursor | A bookmark saying how far through the server's change history you have got. |
| Transaction | A group of database writes that all happen or none do. |
| Conflict | Two devices changed the same record, and only one version can survive. |
| Last-write-wins | The conflict policy where the newest timestamp beats the older one. |
| Field-level merge | Applying last-write-wins per field rather than per record, so two people editing different fields both keep their change. |
| Server-authoritative | The conflict policy where the server's version always beats the local one. |
| Exponential backoff | Waiting longer after each failed retry, instead of hammering the server. |
| Jitter | A small random delay added to backoff so devices do not all retry at the same instant. |
| Clock skew | The fact that device clocks are wrong, sometimes deliberately. |
| Captive portal | Hotel or airport wifi that reports a connection but intercepts every request. |
| Isolate | Dart's unit of execution with its own memory, used to move heavy work off the UI. |
Abbreviations
| Short | Full form | In plain words |
|---|---|---|
| UI | User Interface | Everything the user sees and touches |
| API | Application Programming Interface | The set of calls your app makes to the server |
| HTTP | HyperText Transfer Protocol | The protocol those calls travel over |
| JSON | JavaScript Object Notation | The common text format servers reply in |
| SQL | Structured Query Language | The language for querying relational databases |
| UUID | Universally Unique Identifier | A long random id, unique without asking a server |
| DNS | Domain Name System | The lookup that turns a hostname into an address |
| LWW | Last-Write-Wins | The newest edit overwrites the older one |
| MB | megabytes | A million bytes. A photo is often tens of them |
| ms | milliseconds | A thousandth of a second |
| FAQ | Frequently Asked Questions | The question section near the end |
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.
The useful analogy is a shop till. It records every sale immediately whether or not head office is reachable, and a separate process posts the day's takings upward later. The till never refuses a customer because the line is down, and head office is still the place where two shops' numbers get reconciled.
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
A normal table stores what the thing is. A synced table also has to store what has happened to it: whether the server has seen this version, when it last changed, and how many times sending it has failed.
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.
Going deeper: 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
Pulling means asking the server what has changed since you last asked, rather than asking for everything and working out the difference yourself.
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.
Going deeper: 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.
Going deeper: 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.
| Piece | What it does | What I use | The mistake to avoid |
|---|---|---|---|
| Local database | The 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. |
| Caching | Keeps 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 detection | Decides 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 data | Drains 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 handling | Decides 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 states | Tells 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.
References
Official documentation for the topics covered here.
- pub.dev: drift - the reactive SQLite layer, watched queries and transactions
- pub.dev: isar - the alternative local store, with its own reactive query model
- pub.dev: connectivity_plus - detecting the interface state, and why that is not the same as reachability
- Flutter: Persistence cookbook - the official recipes for local storage, from key value up to SQL
- Dart: Isolates - moving a large sync batch off the UI isolate
- Android: Doze and App Standby - the Android power rules that decide when your background sync is allowed to run
- Apple: BackgroundTasks - the iOS side, where background work is scheduled rather than requested
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.