Build offline first Flutter apps without data corruption: durable outbox pattern, idempotency keys, optimistic concurrency version checks, and safe conflict resolution.
Every offline-first app looks great in the demo: toggle airplane mode, the UI keeps working, reconnect, everything syncs. Then it ships, and three weeks later a user swears they saved a note that vanished, or their edit got overwritten by a stale copy from their other phone. Offline-first isn't hard to start — it's hard to make safe, because the moment writes can happen in two places at once, you've signed up for a distributed systems problem whether you wanted one or not.
I've shipped this pattern in production apps at Shpper and in my own Flutter tools, and the mental model that keeps me out of trouble is simple: the local database is the source of truth for the UI, the server is the source of truth for the world, and a durable outbox is the only bridge between them. Get those three roles clear and most of the corruption bugs disappear.
This post is the whole architecture the way I actually build it — the three-layer split, the outbox schema and drain loop, idempotency keys, optimistic concurrency with version numbers, field-level and semantic merges, tombstones for deletes, and the operational details that separate a slick demo from something you'd trust with a user's data.
The uncomfortable truth is that the instant your app can accept a write while disconnected, you have two writers that can't see each other: the device and the server. That is the textbook setup for a concurrent, partition-prone system. The CAP theorem stops being interview trivia and becomes your Tuesday — during a network partition you're choosing availability (let the user keep writing locally) over immediate consistency, and eventual consistency is the bill that comes due at sync time.
Framing it this way matters because it tells you where the bugs will actually come from. They won't come from the offline experience — caching a read is easy. They come from reconciliation: what happens when a queued write lands on a server whose copy of that record has already moved on. Every technique below exists to make that reconciliation deterministic and non-destructive instead of "whatever happened to arrive last wins."
The single biggest mistake I see is treating "the network" and "the cache" as one blurry thing the repository pokes at hopefully. Split it hard into three roles:
Your UI never awaits the network. It writes locally, enqueues an intent, and moves on. That's what makes the app feel instant, and it's also what makes it correct under bad networks — because a dropped connection can never lose a write that's already committed to disk.
In Flutter terms, this maps cleanly onto a repository that has no idea whether it's online. The widget calls repo.updateNote(...), the repository does a local transaction plus an outbox insert, and returns. A separate sync service — triggered by a connectivity_plus stream, an app-lifecycle resume, or a periodic timer — is the only code that ever touches the API. Your state management (Riverpod, Bloc, whatever you like) subscribes to the local store, so the moment the local write commits, the UI rebuilds. The network is an implementation detail of a background service, not a thing the UI awaits.
// Repository: local-first, network-agnostic.Future<void> updateNote(Note note) async { await db.transaction(() async { await db.notes.upsert(note.copyWith(pendingSync: true)); await db.outbox.insert(OutboxEntry.forUpdate(note)); }); // No await on the network. The sync engine handles that later.}An outbox row needs to carry enough to replay the mutation without the original UI context. In practice I store the entity, the operation, a serialized payload, a client-generated ID, and bookkeeping for retries.
class OutboxEntry { final String id; // UUID generated on-device (idempotency key) final String entityType; // 'note', 'order', ... final String entityId; // stable client-side id final MutationOp op; // create | update | delete final Map<String, dynamic> payload; final int baseVersion; // version the client mutated FROM final DateTime createdAt; int attempts; DateTime? nextRetryAt;}Two fields there do the heavy lifting. The id makes the request idempotent: the server dedupes on it, so a retry after a timeout — where the write actually succeeded but the ACK got lost — doesn't create a duplicate order. The baseVersion is what makes conflict detection possible at all; I'll come back to it.
Generate entityId on the device too, not just the outbox id. Client-generated primary keys (UUIDs or ULIDs) mean a freshly created object has a stable identity before it ever reaches the server. Without that, you get the classic ordering trap: a create is still in flight, the user edits the same object, and your update has no server ID to reference. Assign identity locally and that whole class of bug evaporates.
The drain loop has to be boring and defensive:
Future<void> drainOutbox() async { final pending = await outbox.readyToSend(now: DateTime.now()); for (final entry in pending) { try { final result = await api.apply(entry); // sends entry.id for dedupe await localStore.applyServerResult(result); await outbox.delete(entry.id); } on ConflictException catch (c) { await resolveConflict(entry, c.serverState); } on TransientException { await outbox.scheduleRetry(entry, backoff(entry.attempts)); } // Auth/validation errors: park it, don't spin forever. }}A few rules I never break here:
entityId ordering is enough, and it lets independent entities sync in parallel.random(0, min(cap, base * 2^attempt))) spreads the load and is worth the two extra lines.It's worth being precise about why the client-generated id matters, because it's the single cheapest insurance policy in the whole design. Networks fail in the worst possible way: the request reaches the server, the server commits, and then the response gets lost on the way back. The client sees a timeout and retries. Without an idempotency key, that retry creates a second order, a duplicate payment, a doubled inventory decrement.
With the client sending a stable id, the server does an upsert keyed on it: first time it applies the mutation, every subsequent time it recognizes the key and returns the same result it computed before. The retry becomes a no-op that still gives the client its ACK. This is why the key has to originate on the device and be persisted in the outbox before the first send — a server-generated ID can't help you, because the failure mode you're defending against is "I never heard back from the server."
Here's the part most tutorials wave away. When your write reaches the server, the server's copy may have moved on — the user edited from another device, or a background job touched the record. What you do next decides whether you corrupt data.
Last-write-wins (LWW) means the write with the latest timestamp overwrites everything else. It's tempting because it's one line of code. It's also how you silently destroy work: two devices edit different fields of the same object, both push, and whichever lands second blows away the first entirely — not just the conflicting field. The user who lost their edit gets no error, no warning. That's the exact "I saved it and it's gone" bug.
LWW is only safe when the object is genuinely a single atomic value where the newest intent should fully replace the old one — a toggle, a "last read position," a status flag, a theme preference. For anything with independent fields, it's a footgun. And it's made worse by the fact that it relies on timestamps, which brings device clocks into your correctness logic — more on that below.
The fix starts server-side and it's cheap: every record carries a monotonic version. A write includes the baseVersion it was derived from. The server accepts the write only if baseVersion == currentVersion, then bumps the version. If they don't match, it rejects with the current state — that's the ConflictException above. This is optimistic concurrency control (OCC), and it's the difference between "I might be overwriting something" and "I know I'm overwriting something, here's what."
// Server-side pseudo-logicif (record.version != incoming.baseVersion) { return Conflict(currentState: record); // don't apply blindly}record.data = incoming.payload;record.version += 1;If your backend is Firestore, you get the primitive for this for free: runTransaction re-reads the document and lets you compare a version field (or use the document's own update state) before committing, aborting and retrying on contention. The pattern is identical — read the current version, only write if it's what you expected, bump on success. Whether it's Firestore transactions, an SQL WHERE version = ? guarded UPDATE, or an ETag/If-Match header on a REST API, it's the same optimistic-concurrency idea wearing different clothes.
Once you can detect conflicts, you can resolve them intelligently instead of destructively. The strategy depends on the data shape:
title to X," not "here is the entire note."+3), not the absolute value. A set of tags merges by union; a removal syncs as an explicit remove-intent. Modeling these operations as intents (rather than final values) makes them commutative and naturally mergeable — two +1s from two devices correctly land as +2.For collaborative text or deeply concurrent structures, this is where CRDTs (conflict-free replicated data types) earn their complexity — they're designed to merge concurrent edits without a central coordinator, and libraries exist to bolt them onto a Flutter app. But CRDTs aren't free: metadata overhead, tricky tombstone handling for deletes, and a steeper mental model. For the vast majority of CRUD apps, per-field merge plus version checks plus a rare user prompt covers you. Reach for CRDTs when true real-time concurrent editing is a core feature, not as a default.
pendingSync flag on the local row, beats the lie of instant permanence — and it makes the rare conflict prompt feel like part of an honest system rather than a glitch.Offline-first is a distributed systems problem in a trench coat. The architecture that holds up isn't clever — it's disciplined: read from local, write to an outbox, sync idempotently, and never let last-write-wins touch multi-field data without a version check behind it. Add version numbers, resolve at the field level, use tombstones for deletes, and show the sync state honestly. Do that, and "I saved it and it disappeared" stops being a bug report you dread — because the write was on disk the whole time, waiting patiently for the network to come back.