How to Build Offline-First Data Sync for a Mobile App Without Losing Local Edits

A hand holding a smartphone displaying an app interface

A user opens your app on a subway platform, edits a note, taps save, and loses signal a second later. If your app treats the network as a dependency rather than an occasional convenience, that edit is gone. Offline-first architecture flips the assumption: local storage is the source of truth for the user's immediate experience, and the network is a background process that reconciles state whenever it's available.

This is a different design than "add a retry when the request fails." It changes where writes happen first, how conflicts get resolved when two devices edited the same record, and what the UI shows while data is technically unsynced. Getting it right means users stop noticing the network at all, in the best way.

Write Locally First, Always

The core rule of offline-first design: every write goes to local storage immediately, synchronously, before any network call is attempted. The UI updates from that local write, not from a server response. This single change eliminates the most common offline failure mode, where an app disables its save button or shows a spinner indefinitely because it's waiting on a network round trip that will never come.

hand holding a smartphone with an app interface open
Photo by Dennis Leinarts on Pexels

Local-first storage options vary by platform. On mobile, SQLite remains the most battle-tested choice for structured data, with both iOS and Android exposing it directly or through wrapper libraries. For simpler key-value needs, platform-native stores work fine. The choice matters less than the discipline of always writing there first.

Queue Writes, Don't Fire Them Immediately

Once a write lands locally, it needs to reach the server eventually. The pattern that works reliably is a write-ahead queue: every local mutation gets appended to a durable queue table, and a background process drains that queue whenever connectivity allows, retrying failed sends with backoff.

This decouples the user-facing save from the network entirely. The save button responds instantly because it only has to write locally. Whether that write reaches the server in one second or three hours later on a hotel wifi connection is invisible to the user, aside from a sync status indicator if your design calls for one.

Conflict Resolution Is Where Most Implementations Fall Apart

The genuinely hard part of offline-first sync isn't the local writes or the queue, it's what happens when the same record gets edited on two devices while both were offline, and both queues eventually try to sync. Three strategies handle most real-world cases:

Last-write-wins is the simplest: whichever edit reaches the server last overwrites the other, using a timestamp to decide. It's easy to implement and works fine for single-user, single-device-at-a-time usage patterns, but it silently discards data when true concurrent edits happen, which is a real risk for collaborative or multi-device use.

Field-level merging resolves conflicts per field rather than per record. If one device edited a title and another edited a body field on the same note, both edits survive because they touched different fields. This handles a meaningful chunk of real conflicts without the complexity of a full CRDT implementation.

tablet with a stylus resting on a sketched wireframe
Photo by Beate Vogl on Pexels

Conflict-free replicated data types (CRDTs) provide mathematically guaranteed convergence regardless of the order operations arrive in, which makes them the right choice for genuinely collaborative, multi-writer scenarios. They're more complex to implement correctly, and it's worth reaching for an existing, well-tested library rather than hand-rolling the merge logic, since subtle bugs in a custom CRDT implementation are hard to catch in testing and expensive to discover in production.

Give the UI an Honest Sync Status

Users don't need to understand your queue implementation, but they do need to know whether their data is safely synced or still pending. A quiet, persistent indicator, not a blocking modal, that shows "synced" versus "waiting to sync" versus "sync failed" builds trust that the app isn't silently losing their work. Hiding this entirely, so the app always looks synced even when it isn't, is the fastest way to erode that trust the first time a user discovers an edit never made it to their other device.

"Most offline-first bugs I see aren't in the sync logic, they're in what the UI implies while sync is pending. If the interface looks fully synced when it isn't, users will trust it right up until the moment it fails them." - Dennis Traina, founder of 137Foundry

Handle Deletes as Explicitly as Creates

A local delete needs the same queued, syncable treatment as a create or update, and it needs a tombstone record rather than an immediate hard delete from local storage. If a delete syncs before an unrelated edit from another device arrives, the tombstone tells the sync process the record was intentionally removed rather than simply missing. Skipping tombstones is a common cause of deleted records reappearing after a sync, which reads to users as the app actively losing their intentional actions, arguably worse than losing an edit.

Test the Failure Modes You Can't See in the Simulator

Airplane mode toggling in a simulator doesn't reproduce the failure patterns that show up in the field: a connection that drops mid-request rather than being fully absent, a flaky connection that intermittently succeeds, a device that goes offline for days rather than minutes. Testing with a network conditioning tool that simulates latency, packet loss, and mid-request drops surfaces bugs that a clean on/off toggle never will. It's worth budgeting real time for this kind of testing before shipping, since sync bugs that only appear under flaky, not fully-offline, conditions are some of the hardest to reproduce from a bug report alone.

Batch Sync Requests Instead of Firing One Per Record

A queue that fires a separate network request for every individual mutation works, but it wastes battery and hits rate limits fast on a busy sync backlog after a device comes back online following an extended offline period. Batching queued mutations into a single request, up to a reasonable payload size limit, cuts the number of round trips dramatically and makes the sync process resilient to a backlog of hundreds of queued writes accumulated over a weekend without connectivity.

This also simplifies server-side conflict detection, since the server can evaluate an entire batch's worth of changes against the current state in one pass rather than handling interleaved single-record requests from multiple devices arriving in an unpredictable order.

Decide What Happens When Storage Fills Up

Local-first storage isn't infinite, and a queue that never drains because the device has been offline for an extended period can eventually hit a device storage limit. Deciding in advance how your app handles this, whether that's warning the user, pruning old synced records, or capping how much unsynced data can accumulate locally, prevents an edge case from becoming a data-loss incident. This matters more for apps with rich media attachments than pure text data, since a queue full of unsynced photos or videos fills storage far faster than a queue of text edits.

Background Sync Needs Its Own Retry and Backoff Strategy

A background sync process that retries a failed request immediately, in a tight loop, drains battery and can worsen network congestion for the user's other apps. Exponential backoff, where each failed retry waits longer than the last up to a reasonable ceiling, is the standard approach, and most mobile platforms provide background task scheduling APIs specifically designed for this kind of deferred, battery-conscious retry pattern rather than a hand-rolled timer loop.

Plan for Schema Changes Without Breaking Old Queued Data

If a queued mutation was created under an older version of your app's data schema and the sync attempt happens after the user updates to a new version with a changed schema, the sync process needs to handle that mismatch gracefully rather than crashing or silently dropping the mutation. Including a schema version alongside the protocol version in each queued record, and writing explicit migration logic for at least one version back, avoids a class of bugs that only shows up for users who go offline right before an app update and sync afterward.

Version Your Sync Protocol From Day One

Once an app ships with a sync protocol, changing the data format later means handling both old and new client versions simultaneously, since users don't all update at once. Building a version field into your sync payloads from the very first release, even if you only have one version for a while, saves a genuinely painful migration later when you need to change the conflict resolution strategy or add a new field to the sync record.

data center hallway lined with server racks
Photo by panumas nikhomkhai on Pexels

Putting It Together

The pattern that holds up in practice: write locally and synchronously first, queue every mutation durably for background sync, choose a conflict resolution strategy that matches how collaborative your data actually is, give users an honest sync status, and treat deletes and version changes with the same care as everyday edits. None of these pieces are exotic individually, but skipping any one of them is usually where "the app lost my data" bug reports come from.

This is a pattern 137Foundry's app development team builds into mobile and hybrid apps regularly, because the alternative, a support queue full of "I lost my changes" tickets, costs far more in trust than the upfront engineering work.

If you're evaluating whether your current app architecture handles this well, or you're scoping a new build that needs it from the start, 137Foundry can walk through the specific sync requirements for your product. Learn more about how the team approaches this on the services page, or read more about the team on the about page.

For deeper technical references, web.dev covers modern offline storage APIs in detail, MDN documents the underlying browser and platform storage primitives, and both Android's developer documentation and Apple's developer documentation cover platform-specific local storage and background sync APIs directly from the source. SQLite's own documentation is worth reading directly if you're building the local storage layer yourself rather than through a higher-level wrapper.

Need help with Web Development?

137Foundry builds custom software, AI integrations, and automation systems for businesses that need real solutions.

Book a Free Consultation View Services