Articles · How-To GuidesUpdated October 2026

Offline mode is a local store plus a queue, and the online flag lies to you.

To add offline mode you keep the data the app reads on the phone, write every change the person makes into a local queue, and send that queue when the network comes back. In an Expo and React Native project each of those three jobs has a package behind it, and the documentation is specific about what each one does. It is also specific about something most tutorials skip: on iOS the field that is supposed to tell you whether the internet is reachable just mirrors the field that tells you a radio is on. Every fact below comes from Expo and React Native documentation read on 22 October 2026. If you have not built the app yet, start with make an app.

What follows is how to read the connection, where to put the writes, and the one thing in this area with a documented and often misquoted answer: what actually survives when somebody deletes your app and installs it again.

See where each piece of data belongs

The short version

Read from the phone always. Send from a queue.

The version of offline mode that works has the app reading from a local database on every screen, every time, with the network as a background job that refreshes that database. The version that breaks reads from the network first and falls back to local storage when a request fails, because then every screen has two code paths and only one of them gets tested.

Writes work the same way. A tap that changes something goes into a local table as a pending row, the screen updates from that table, and a separate job drains the table whenever it can. The person who tapped does not wait for a response and does not see a spinner that never ends. Build it in that order and offline mode stops being a feature you bolt on, because the app already works that way with the plane on the ground.

How do you tell whether the app is really online?

The package is expo-network, and it gives you three ways in. There is a hook, useNetworkState(), which returns the current state and, in the reference's own words, starts a listener for network state changes and cleans up before unmounting. There is a one-off read, Network.getNetworkStateAsync(). And there is Network.addNetworkStateListener(), which fires whenever the state changes and hands back a subscription with a remove function. On Android the ACCESS_NETWORK_STATE and ACCESS_WIFI_STATE permissions are added automatically, so there is nothing to put in your config.

All three give you the same object, with three fields, and the two you care about do not mean the same thing. The isConnected field says there is an active network connection, and Expo's own description adds a warning in the next sentence: this does not mean that internet is reachable. It is false when the connection type is NONE or UNKNOWN and true for everything else. So a phone attached to a café access point with an unpaid captive portal reads as connected, because it is.

The isInternetReachable field is meant to be the stronger one, and on Android it is. The reference says it requires the active network to have internet capability, confirmed internet access and a usable connection state, and that a VPN connection also needs non-zero downstream bandwidth. Then comes the sentence that decides how you write the rest of your app: on iOS, this value will always be the same as isConnected. There is no second signal on iPhone. The café Wi-Fi reads as reachable there too, and nothing in expo-network will tell you otherwise.

Which leads to the rule worth taking away. Do not gate a send on the flag. Send, and let the request fail. A request that fails is the only test of the network that cannot be wrong, and it is free because you were going to make it anyway. Use the network state for the other job, deciding when to try again, where being roughly right is good enough. The connection type is also thinner on iOS than it looks: the NetworkStateType enum lists BLUETOOTH, VPN, WIMAX and OTHER as Android only, so an iPhone on a VPN reports whatever transport sits underneath it instead.

Expo Network API reference, SDK 58, read 22 October 2026

Check yours

Where each piece of offline data belongs

Tick what your app has to hold while the phone has no network. Each answer names the Expo package and what happens to that data if the person deletes the app and installs it again.

Nothing ticked. An app that only reads and never writes needs a cache and no queue, which is the cheap version of offline mode.

Where do the writes go while the phone is offline?

Into a table, with one row per pending change. expo-sqlite is the package for it. Its reference says the database is persisted across restarts of your app, which is the baseline you need, and it gives you runAsync() for writes with bound parameters. Use that rather than execAsync(), which the same page flags as not escaping parameters and liable to SQL injection. The reference also suggests turning on WAL journal mode when you create a new database, with a PRAGMA journal_mode call, for general performance.

A row needs four things: what happened, what it happened to, when it was queued, and how many times you have tried to send it. The try count is the one people leave out. Without it a single change the server will never accept blocks everything behind it, and your customer watches a spinner for a week. With it you can park a row after a few failures and show the person one honest message about the thing that did not go.

There is a trap in the transaction API worth knowing before you hit it. Expo documents that because of how async and await work, any query running while a withTransactionAsync() transaction is active gets included in that transaction, including statements outside the function you passed in, and the reference calls this surprising behaviour in those words. A background refresh that commits while your drain is open is now inside your transaction, so rolling back a failed send takes the refresh with it. The documented fix is withExclusiveTransactionAsync(), where only queries inside the scope function, run against the txn object it hands you, are in the real transaction.

The other packages have narrower jobs. Async Storage, which Expo describes as asynchronous, unencrypted, persistent, key-value storage, suits small things such as the last filter somebody chose. Expo's Store data guide recommends it for small amounts of data that need no encryption, such as user preferences or app state. For files, expo-file-system splits them in two, and the SDK 58 reference reaches those directories as Paths.document and Paths.cache. Paths.document is a place to store files that are safe from being deleted by the system. Paths.cache is a place to store files that can be deleted by the system when the device runs low on storage. Put a photo waiting to upload in the document directory. Put a thumbnail you can fetch again in the cache. If you are working from an older tutorial, check the names: the current reference has no documentDirectory or cacheDirectory property, and it marks the old FileSystem functions as deprecated with an instruction to import them from expo-file-system/legacy. Tracking how often the queue drains is a job for add analytics, and the failures that leave rows stuck belong in add crash reporting.

Which Expo storage survives an uninstall and a reinstall?

One package documents an answer, and it is narrower than the question. Expo's Store data guide, last modified 3 June 2026, lists four ways to keep data on the device: expo-secure-store, expo-file-system, expo-sqlite and Async Storage. Only the expo-secure-store reference says anything about what happens when somebody deletes the app. The other three describe surviving restarts. Those are different words for a reason.

Here is what that one page says. On iOS, values go into keychain services, and because of the underlying nature of the iOS Keychain, data stored with expo-secure-store will persist across app uninstallations when the app is reinstalled with the same bundle ID. On Android, data saved using expo-secure-store will not be preserved upon app uninstallation. So the behaviour you might be counting on exists on one platform of two, and it is a side effect of how the Keychain manages credentials rather than a feature anybody built.

The same page then tells you not to lean on it. Its data persistence section says the package is designed for persistence across app restarts and updates, and that it is important not to rely on it as a single source of truth for irreplaceable, critical data. On the iOS line it goes further: this is not guaranteed and you should never rely on this implementation detail. Read the two sentences together and the honest answer to the question is that Expo documents no storage option intended to survive a reinstall. The one that does survive, on one platform, comes with an instruction not to trust it.

Android closes the other door too. Android Auto Backup has to be configured to exclude the expo-secure-store shared preferences, because it is impossible to decrypt them after a restore: the app's entries are deleted from the Android Key Store when the app is uninstalled. If your project has no custom backup configuration, the package sets that exclusion up for you, which is what the configureAndroidBackup option does and it defaults to true. If you wrote your own backup rules, exclude SecureStore under the sharedpref domain and turn the option off. All of which gives you one design rule: anything the person expects to find waiting after a reinstall lives on a server, keyed to an account they can sign back into. Everything on the phone is a cache, including the queue.

Expo SecureStore API reference, SDK 58, read 22 October 2026

When should the app try to send the queue?

Three triggers cover almost everything. The first is a failed send, retried on a growing delay. Since the flags cannot tell you on iOS whether the internet is really there, the request is your test, and a failure is the clearest result you will get. The second is a change reported by expo-network, through the hook or the listener, which usually means the radio came back. Treat it as a nudge to try rather than proof it will work.

The third is the app coming back to the foreground, and that one comes from React Native rather than Expo. AppState tells you whether the app is in the foreground or the background and notifies you on change. The documented states are active, meaning running in the foreground, and background, meaning the person is in another app or on the home screen. On iOS there is a third, inactive, for the transition between the two and for periods such as the multitasking view or an incoming call. React Native's AppState page, in the 0.87 documentation and last updated 27 September 2026, lists one more detail worth knowing: on the legacy architecture, AppState.currentState is null at launch until it has been fetched from the native side. Read it at launch and you may get nothing, so listen for the change event instead.

Android has two extra events on the same API, focus and blur, which fire as the person starts and stops interacting. The documentation notes that pulling down the notification drawer fires blur while AppState itself does not change. That is a small thing, and it is the kind of small thing that makes a drain run twice. Pick one trigger per platform and keep a flag so two drains never overlap.

There is a fourth trigger that runs with the app closed, and it is documented with numbers. expo-background-task performs a deferrable unit of work outside your app's lifecycle, and the reference names syncing data with a server as the example use. The system picks the moment, not you. The minimumInterval option defaults to once every 12 hours and the minimum is 15 minutes, and the same entry says the system treats your value as a minimum delay, that tasks will not run exactly on schedule, and that on iOS short intervals are often ignored because the system tends to run background tasks in windows such as overnight. A task also runs only if the battery has enough charge, or the device is plugged in, and the network is available. Without those conditions it does not execute. Figures read on 22 October 2026 from the SDK 58 reference.

Two limits decide whether it is worth wiring up. Background tasks are stopped if the person kills the app and resume when the app is restarted, and killing it means different things per platform: on iOS, swiping the app away in the app switcher fully terminates it, while on Android removing it from the recent apps list does not, though Expo warns that Android behaviour varies by device vendor. The second limit bites sooner. Apple's Background Tasks API is unavailable on iOS simulators and works only on a physical device, so this is the one trigger you cannot test where you test everything else. Build for a queue that drains while the app is open and treat the background run as a bonus. Then tell the person what is waiting. A count of unsent changes in a quiet corner of the screen does more for trust than any retry logic, because it turns a mystery into a number.

Expo Network API reference, the state change listener, read 22 October 2026

The foreground lines above come from React Native's AppState page in the 0.87 documentation, last updated 27 September 2026. The background figures come from Expo's BackgroundTask reference for SDK 58.

What survives what, in Expo's own words

The third column is the honest one. Three of these five options make no claim about an uninstall anywhere in their documentation, so the cell says so rather than guessing on their behalf. Read on 22 October 2026 against the SDK 58 reference and the Store data guide.

OptionHow Expo describes itSurvives an app restartSurvives an uninstall
expo-sqlitePersisted across restarts of your appYesNot stated in the reference
Async StorageAsynchronous, unencrypted, persistent, key-valueYesNot stated in Expo's page
expo-file-system, Paths.documentSafe from being deleted by the systemYesNot stated in the reference
expo-file-system, Paths.cacheCan be deleted by the system when the device runs low on storageNot guaranteedNot stated in the reference
expo-secure-storePersistent across app restarts and updatesYesiOS yes, same bundle ID, and Expo says never rely on it. Android no.

Expo SecureStore API reference, data persistence section, read 22 October 2026

Where this lands if a builder generated your app

Newly is an AI app builder. You describe an app in plain English and it writes a real React Native and Expo project in TypeScript that you own, runs it on cloud iOS and Android simulators while it builds, and ships builds to TestFlight and to Google Play internal testing. There is a standalone Android APK and two way GitHub sync. It is $25 a month on effort-based credits, with no free plan. For offline mode the useful part is that these are the same packages: expo-sqlite, expo-network and expo-secure-store go into the project the same way they would if you had typed the commands yourself, and you can read every line afterwards.

A new app from any builder starts local anyway. Data on the phone, no accounts, no server. That version is offline by accident, and it fails the reinstall test as hard as any other, because a database in the app's own storage goes when the app goes. Ask for sign-in and syncing and the agent adds Newly Backend, which brings authentication, an API service, a Postgres database per environment and file storage. That is the point at which a queue starts earning its keep, because there is finally a somewhere for it to drain to.

The honest limit is the same for generated code and hand written code. No tool can decide for you what happens when two phones change the same row while both were offline. That is a product question about whose edit wins, and you have to answer it before the first customer finds out. Get the queue working, then publish to app store with aeroplane mode as the last thing you test before you upload.

Questions people ask about adding offline mode

Keep the data the app reads in a local store, write every change the person makes into a local queue, and send the queue when the network comes back. In an Expo and React Native project that is expo-sqlite for both the local copy and the queue, expo-network to notice when the connection changes, and React Native's AppState to flush the queue when the app returns to the foreground. The order matters: build the app so it reads from the local store all the time, rather than reading from the network and falling back to the store when a request fails.

Describe the app, then switch the network off

Build it reading from the phone, queue the writes, and test it in aeroplane mode on a real device before anyone else sees it. Half an hour with the radio off finds more than a week of reading about network states.

Start building