Mobile app offline sync is one local copy and one queue you have to design yourself.
A phone loses the network in a lift, a basement, a warehouse aisle and a train tunnel. It loses it in the middle of requests, not politely between them. Mobile app offline sync is the work of making that boring: the screen reads from a copy on the device, and every change waits in a queue until it can be sent. Nothing in React Native or Expo does that for you by default. If you are still deciding whether your app needs any of it, start with offline app functionality and come back here for the mechanics.
This page covers what happens to a write when the radio drops mid request, and where the local copy belongs. It then covers draining a queue when the connection returns without making duplicates, and settling two people who edited the same record.
See where a half sent write ends upThe short version
Reads need a copy on the device, writes need a queue that outlives the screen.
Those are the two halves, and they fail differently. A read that fails can be answered from data already on the phone. A write that fails has to be stored somewhere that survives the user closing the app. It also has to be sent in a way that a second attempt cannot create a second record. Backoff, sync banners and conflict rules all hang off those two jobs.
The detail that decides the design is this: a phone cannot tell a request that never arrived from a request that arrived and whose reply was lost. That one ambiguity is why an offline write needs an identifier chosen on the device rather than handed out by the server.
What happens to a write when the network drops
Three different moments get called the same thing, and only one of them is easy. The request never leaves the phone, so nothing happened anywhere and you just need to keep the value. The request leaves, the server commits the row, and the reply never comes back, so the phone believes it failed and the server believes it worked. Or the app is closed while a change is still waiting, which is the one that loses data with no error at all.
A default Expo project has no answer to any of the three. The fetch call rejects, the promise you were awaiting throws, and the value the user typed exists only in a component that is about to unmount. Retrying in a catch block covers the first case while the screen stays open and covers nothing else.
Worth being exact about this site's own builder, because vagueness here is what costs people data. A Newly build talks to its backend through a small wrapper around fetch. The wrapper attaches the bearer token, throws when the response status is not OK, and parses the JSON. That is all it does: no queue, no retry, no local copy. A write interrupted mid flight is therefore gone unless the screen kept the value. An offline queue is something you have to ask for and describe. That is the documented default path rather than a reading of every generated project, so open your own code before relying on it either way.
Where the write ends up
Pick how the app sends a change, then pick the moment the network goes.
How the app writes
When the network goes
An offline first app reads from the device, always
An offline first app inverts the order of a normal screen. The list you render comes from local storage every time, and the network is a background job that updates that storage. There is no spinner where a row should be, because the row is already there. The status of the connection changes a small banner, not the architecture.
An Expo project has three sensible places to keep that copy. AsyncStorage is a key value store, fine for settings and a small cache. On Android it defaults to a 6 MB database, which you raise with a Gradle property. The expo-sqlite package is a real relational database, included in Expo Go, with parallel async and sync APIs. Its docs recommend turning on WAL journal mode when you create the database. It also ships expo-sqlite/kv-store, which the docs describe as providing the same API as AsyncStorage, so you can move between the two without rewriting call sites. The third option, react-native-mmkv, is faster but it is a native module, so it needs a custom development build rather than Expo Go.
Choose by the shape of the query, not by a benchmark. If a screen filters, sorts, joins or counts, it wants a relational store and a schema. That is the exercise in mobile app database design, plus the three columns sync needs on every table: an id generated on the device, a last changed timestamp, and a flag for rows the server has not accepted yet. Files never belong in the database. Photos and documents go on the filesystem and the row keeps the path.
Syncing data when back online is not one moment
Syncing data when back online sounds like an event you can subscribe to. It is not. The operating system reports a connection as soon as the radio associates. That connection may be a hotel portal that swallows your request, or one bar that stalls it until it times out. The app is also usually not running. Both platforms allow some background work, but neither promises your queue will drain while the phone sits in a pocket. Treat the next launch or foreground as the main opportunity, and a connectivity event as a hint.
An outbox is a table, not a concept. One row per pending change, holding an id generated on the device, the entity and its local id, the method, the JSON body, a created timestamp, an attempt count and the last error. Screens append to it and never call the network themselves. One worker reads it in order, sends the oldest row, and deletes a row only when the server has acknowledged it. Failures increase the attempt count and back off, so a queue behind a broken server slows down instead of hammering it.
The device generated id is the whole defence against duplicates. If the phone chose the id, a repeat of the same row is the same record, and the server can recognise the second arrival and return the first result. If the server chose the id, a lost reply leaves you no way to ask whether the first attempt landed. The honest choices then are a duplicate row or a discarded write.
Payload size decides more of the design than anything else. Ten text rows replay in a second. Forty photos from a day in the field are a different job. Upload each file as its own request, keep the parent row pending until its uploads finish, and never put image bytes in the queue payload. If pictures are central to the app, read react native camera first, because where the file gets written decides how it is uploaded later.
Conflict resolution when two people edited the same record
Conflict resolution on mobile is a normal case, not an edge case. Two technicians open the same job, both edit it with no signal, and both come back within the hour. Last write wins is the default nearly everywhere and it is the option that loses data quietly. The second sync overwrites the first, nobody is told, and the missing note surfaces a week later in an argument.
Apache CouchDB is worth reading on this even if you never use it, because it takes the opposite position. When a document has been updated independently in two places, replication keeps both versions. It picks a winner with a deterministic algorithm, so that every replica makes the same choice, and keeps the losing revisions in the revision tree as conflicts. Asking for the document with conflicts=true returns a member listing the other conflicting revisions. The point is that the database refuses to decide what the data means: the application has to show the versions and build a merged one.
For most apps the workable middle is to merge per field instead of per record. Store the time each field changed, take the newest value for each field, and involve a human only when the same field changed twice. Use the server clock for those timestamps, never the device clock, because a phone's clock is user settable and one wrong device otherwise wins every conflict forever. Status fields are the exception, since two transitions cannot be averaged: for those, the first transition to arrive wins and the other person gets told what happened to theirs.
What each level of offline support actually gives you
| Approach | Screens open with no signal | Writes survive the app closing | Safe to retry | Handles two people editing |
|---|---|---|---|---|
| Fetch on every screen | No | No | No | No |
| Cached reads, direct writes | last loaded data | No | No | No |
| Local database, writes sent live | Yes | No | No | No |
| Local database plus an outbox | Yes | Yes | with a device generated id | No |
| Outbox plus a merge rule per field | Yes | Yes | with a device generated id | Yes |
Building the sync layer into your own app
First decide whether you need a queue at all. An app that reads a lot and writes a little needs a cache, an honest banner and nothing else. An app whose whole purpose is capturing data away from signal needs the outbox in version one. Retrofitting a queue means touching every write in the app and re-testing all of them. The cheap middle position, worth knowing about, is to queue only the two or three actions that happen in the field and let the rest of the app require a connection.
Newly is an AI app builder. You describe the app in plain English and it writes a real React Native and Expo project you own. It runs that project on a cloud iPhone or Android simulator while it builds, and ships it to TestFlight with your own Apple Developer account. The Deploy tab publishes to Google Play internal testing and also builds a standalone APK with the JavaScript bundled. Plans are $25 a month and there is no free plan. Ask for the local table and the outbox in those words, because a prompt that says the app should work offline gets you a cache and not a queue. The code is yours to read: npm i -g @newly/cli, then newly pull with your project id, and the sync worker is a file like any other.
If you would rather see the pattern inside a finished app than in the abstract, offline work in practice is this same queue with a job sheet on top. It also shows what the technician sees while rows are still pending.
Questions people ask about offline sync
It is two things working together. A copy of the data on the device, so screens open with no connection, and a queue of pending changes that the app replays when a connection returns. Sync is the process of reconciling those two with the server, including deciding what happens when the same record changed in both places.
Describe the offline part before anything else
Write down which screens have to open with no signal, and which actions have to survive a closed app. Then build the queue into the first version rather than the fourth.
Start building