Offline-first sync is the part of mobile development that looks trivial in a demo and turns into a swamp the moment a second device, a flaky tunnel, or a deleted record shows up. I've built three apps that each handle it differently — PokiPaw, Rețetele Mele, and FocusGuard — and the patterns that survived contact with reality are the same handful, reused with small variations. This post walks through the ones I keep reaching for: local-first writes, a sync queue, last-write-wins conflict resolution, tombstones for deletes, and versioned delta sync. It's deliberately honest about where each one bites, because the failure modes are the actual lesson.
The starting premise is simple. A phone is not reliably online, and a user who taps "save" and watches a spinner stall on the subway loses trust fast. So the write has to land locally first and reach the server later. Everything downstream is the consequence of that one decision.
Local-first writes: the only non-negotiable
The rule across all three apps is the same: the UI writes to a local store synchronously, the network is a background concern.
In Rețetele Mele, my recipe organizer, local writes go straight into Room (SQLite) and the UI reflects them instantly. A SyncManager then propagates changes to a Django server on its own schedule. In PokiPaw, the pet-care app, a household co-manages a pet across phones, so a write — "logged the evening meal" — has to feel immediate even though it eventually has to reconcile against everyone else's edits on Supabase. In FocusGuard, the screen-time app, it's inverted: most of the syncable state flows down from Firestore (the message bank, the GIFs), so local-first there is mostly about reading from cache and only occasionally writing.
That last contrast matters. "Offline-first" isn't one architecture. It's a spectrum from write-heavy collaborative state (PokiPaw) to read-heavy content delivery (FocusGuard), and the sync machinery you need is wildly different at each end. Picking the wrong one — building a full bidirectional conflict resolver for what is really a content cache — is a classic way to over-engineer.
The sync queue: making writes durable across a dropped connection
Once writes are local, you need something to ferry them to the server reliably, retrying when the network comes back. On Android I lean on WorkManager for exactly this. Rețetele Mele schedules background sync work through it, so a write made on the subway gets flushed when connectivity returns, surviving the app being backgrounded or killed. FocusGuard uses the same tool for its content sync.
The valuable property of WorkManager (and equivalents) is that the OS owns the retry and backoff. You hand it a unit of work with constraints — "needs network" — and it persists that intent across process death. The trap is treating it as instant: it is not. There's latency between "user did the thing" and "server knows," and your UI has to be comfortable showing local truth in that gap without pretending it's been confirmed.
On the PokiPaw side the queue is less explicit, because Supabase Realtime is pushing changes both ways and the reconnect logic is more about replaying what happened while you were dark than draining a strict FIFO of pending writes. Same idea, different shape: buffer locally, reconcile on reconnect.
Conflict resolution: last-write-wins, and why it's a loaded gun
When two devices edit the same record and both eventually reach the server, somebody has to lose. The cheapest answer — and the one Rețetele Mele uses — is last-write-wins (LWW): the most recent write, by timestamp, overwrites the rest. Server-side it's a server-wins LWW; the backend is the arbiter.
LWW is genuinely fine for a lot of cases. A single user editing their own recipe collection across a phone and (eventually) a tablet almost never produces a real conflict, just a stale copy that gets corrected. It's a few lines of logic instead of a CRDT library, and for an MVP that's the right trade.
But LWW is a loaded gun, and you should know which way it's pointed. It resolves conflicts by discarding data, silently. If two people in a PokiPaw household edit the same medication schedule from two phones — one bumps the dose, the other changes the time — LWW doesn't merge those intentions; the later write wins outright and the earlier edit vanishes with no warning. That's why on PokiPaw I treat concurrent multi-writer editing as the part I most want to harden, not a solved problem. The honest position is: LWW is correct until two humans genuinely disagree about the same field, and then it's quietly wrong. The fix isn't a fancier algorithm so much as surfacing the conflict — showing the user that two edits collided — which most apps, including mine, under-invest in.
Tombstones: the delete gotcha that bites everyone
Here's the bug that every offline-first developer rediscovers personally. You delete a record on device A. Device B never heard about the deletion. Next sync, device B has a copy the server no longer does, so it helpfully "re-creates" it — and the deleted thing rises from the dead.
Hard deletes and sync don't mix. A row that's simply gone is indistinguishable, from a syncing peer's point of view, from a row that never arrived. The standard answer is the tombstone: you don't delete the row, you mark it deleted (a soft-delete flag plus a timestamp) and let that marker sync like any other change. Peers see "this was deleted at T" and remove their local copy instead of resurrecting it.
This is the tombstone/soft-delete gotcha I hit on Rețetele Mele, and it's worth stating plainly because it's so easy to ship the naive version and not notice until a tester deletes a recipe that won't stay dead. Tombstones come with their own tail: they accumulate, so eventually you need a reaping policy (drop tombstones older than the longest plausible offline window), and you have to make sure every read path filters them out so deleted rows don't leak back into the UI. None of it is hard. All of it is easy to forget.
Versioned delta sync: only fetch what actually changed
The third pattern is about not re-downloading the world. FocusGuard ships a bank of motivational and "busted" overlay messages plus GIF reactions, all stored in Firestore. The naive approach re-pulls every collection on launch. Instead, FocusGuard does version-diff content caching: it keeps a version marker per collection, compares against the server's, and re-fetches only the collections that drifted. If the messages haven't changed but the GIFs have, only the GIFs come down.
This is the read-heavy mirror of the write queue, and it's a pattern I'd reach for any time the client caches server-owned content that changes occasionally and unpredictably. The win is bandwidth and battery; the cost is that you now have versioning metadata to keep honest. If your version marker lies — you bumped the data but forgot to bump the version — clients silently serve stale content, which is a maddening class of bug because nothing errors. The discipline is to make the version bump inseparable from the data change, ideally in the same write path, so the two can't drift.
Picking the pattern
If I compress three apps' worth of scar tissue into guidance: write locally first, always. Use the platform's durable background scheduler (WorkManager or its kin) so writes survive process death. Reach for last-write-wins when conflicts are rare and the data is single-owner — but the day you have genuine multi-writer fields, plan to show conflicts rather than silently eat them. Use tombstones the instant deletes have to sync, and budget for reaping them. And when the client mirrors server-owned content, version your collections and fetch only the deltas.
None of these are exotic. The skill isn't knowing the patterns; it's matching them to the actual shape of your data and refusing to build the heavyweight version when the lightweight one fits. Over-building sync is how solo projects die — a lesson that runs through everything in building 11 products as a solo founder.
If this is the kind of hands-on architecture writing you want more of, I send the occasional note when a new piece lands — the newsletter is here.