Skip to content
Back to blog
Building software

How do offline-first apps keep working without a connection?

Offline-first apps write to a local database first and sync through a queue. One save step by step, plus retries and conflicts, from Android, Flutter and MDN.

Intermediate9 min read

On this page

An offline-first app always reads from and writes to a database on the device first. Anything that still has to reach the server goes into a queue, and when the device is back online the queue is sent to the server one item at a time. The screen never waits on the network, and the user can save at any moment.

This post follows that path: where the local data lives, what happens step by step when someone saves with no signal, and the two places these apps usually go wrong, which are conflicting edits and requests that get sent twice.

Offline, three writes are saved to the local database and queued in an outbox; when the signal returns, the outbox sends them to the server in order

What is an offline-first app?

Android's architecture guide defines it as an app that can perform all, or a critical subset of its core functionality without access to the internet. Flutter's guide says much the same: an app capable of offering most or all of its functionality while being disconnected.

The Android page lists the situations it has in mind: limited bandwidth, short interruptions such as an elevator or a tunnel, and devices that are only online now and then, such as Wi-Fi-only tablets (Android). Ink & Switch's 2019 essay on local-first software adds one more: in developing countries or rural areas, infrastructure for Internet access is sometimes patchy.

To count as offline-first, the Android guide says the app must (Android):

  • stay usable without a reliable network connection;
  • show local data immediately instead of waiting for the first network call to complete or fail;
  • fetch data with battery and data use in mind, for example only when charging or on Wi-Fi.

The second point is the one that changes the design. A typical app sends a request and then draws the screen. An offline-first app draws what it already has, then updates when newer data arrives.

Where should the local data live?

In a database that is written to disk, and it should be the only thing the UI reads. Android calls it the local data source and makes it the canonical source of truth for the app, the exclusive source that higher layers read from. The network data source is reached only through the repository; the UI never talks to the network directly.

Platform Local store Reference
Android Room (relational), DataStore, or plain files Android
Flutter A SQL database behind a DatabaseService in the guide's example Flutter
Web IndexedDB for structured data, the Cache API for request/response pairs MDN IndexedDB, MDN Service Worker

For the page's own files (HTML, CSS, JavaScript), a service worker can answer from a cache. Jake Archibald's offline cookbook on web.dev calls "cache, falling back to network" the pattern ideal for building offline-first, and notes that non-GET requests cannot be cached. Writes therefore need a queue of their own, which is the next section.

Two things to know before you make IndexedDB your main store. It is a transactional database system that stores objects by key, works asynchronously, and follows the same-origin policy. And web storage is best-effort by default: the browser may evict it when the device runs low on space. A web app holding data that exists nowhere else should ask for persistent storage with navigator.storage.persist(), and every write should sit in a try...catch, because going over quota fails with a QuotaExceededError.

What happens, step by step, when a user saves with no signal?

The app writes locally, puts the change in a queue, and sends it later. Android calls this a lazy write and says it is the correct choice when the data is critical to the app, such as tasks a user adds to a to-do app while offline. Here is one edit to one task, from tap to server.

Step 1: write to the local database

The app saves the new values in its local tasks table and marks the row as not yet synced. Flutter's guide suggests a flag such as bool synchronized on the data class; only rows where it is false are sent. Because the UI reads this table, the change is on screen at once.

Step 2: put the request in the queue

The request goes into an outbox that also lives on disk, so it survives the app being killed or the phone restarting. On Android this is usually handed to WorkManager, which stores scheduled work in an internally managed SQLite database and reschedules it across reboots.

Store enough to replay the request:

{"op": "PUT", "path": "/tasks/7f3c2a10", "body": {"title": "Fix the fence", "done": true}, "baseVersion": "v3"}

7f3c2a10 is an id the app generated when the task was created, and baseVersion is the version the device saw before the edit. Both come back in step 4 and in the conflicts section.

Step 3: wait until sending makes sense

Don't retry in a tight loop. WorkManager takes constraints such as an unmetered network or enough battery. The Now in Android sample sets its sync work to NetworkType.CONNECTED, so it waits until the network is available before it runs. On Flutter, the guide points to the workmanager plugin for background sync and to connectivity_plus and battery_plus for checking the network and battery first (Flutter).

Step 4: send in order, retry with backoff

Once online, the app sends the queue in order. On failure it waits and tries again at growing intervals (exponential backoff). The Android guide says to retry network calls that return an error that indicate a lack of connectivity, but not to retry unauthorized requests until proper credentials are available. In Now in Android, a failed sync returns Result.retry() and WorkManager will automatically retry synchronization with exponential backoff.

Step 5: close the item

When the server accepts the request, the app removes it from the outbox and flips the local row to synced. If it fails, the flag stays false and the row goes out on the next run (Flutter).

How does the app know it is back online?

Let the OS or browser tell you, then confirm by actually sending a request. The "online" flag you can read is not reliable on its own.

On the web, navigator.onLine comes from browser and OS heuristics. MDN notes that connection to LAN is considered online, even though the LAN may not have Internet access, and concludes that the property is inherently unreliable: don't disable features based on it, only show hints.

To have the system wake your code when the network returns, the web has the Background Synchronization API, which lets an app defer tasks so that they can be run in a service worker once the user has a stable network connection. MDN marks it as not Baseline because it does not work in some of the most widely-used browsers, so keep a fallback such as draining the queue every time the page opens. Service workers themselves only run in secure contexts: pages served over HTTPS, plus http://localhost for development.

If two devices edit the same record offline, whose change wins?

It depends on the data. The simplest rule is last write wins, and it simply discards the other side's change.

Last write wins

Android says that for mobile apps a common approach is "last write wins." Devices attach a timestamp to what they write, and the server discards any data older than its current state while accepting those newer.

An example: device A renames a task to "Fix the north fence" at 09:10. Device B marks the same task done at 09:12. Both are offline. When they sync, B's whole row wins and A's new title is gone, with no message to either user. If the timestamps come from each phone's clock, the outcome also depends on how closely those two clocks agree.

Check the version before writing

If losing data like that is not acceptable, have the server reject writes based on an old version. HTTP already has the mechanism. If-Match makes a method conditional on the current entity tag matching the one sent, and RFC 9110 describes its use to prevent accidental overwrites when multiple user agents might be acting in parallel on the same resource, the "lost update" problem. A mismatch gets 412 Precondition Failed.

PUT /tasks/7f3c2a10 HTTP/1.1
If-Match: "v3"
Content-Type: application/json

{"title": "Fix the fence", "done": true}

On a 412, the app fetches the latest version and merges it, or asks: "This task was renamed on another device. Which title do you want to keep?"

Merge per field, or use a CRDT

You can also merge field by field instead of overwriting whole rows. In the example, A's title and B's done flag can both survive, since they touched different fields. That idea underlies CRDTs. The Ink & Switch essay explains that if the state was concurrently modified on different devices, the CRDT merges those changes, and that the only change it cannot resolve on its own is when multiple users concurrently update the same property of the same object. In that case the CRDT keeps the conflicting values for the app or the user to resolve.

My view: most apps don't need to start with a CRDT. Version checks with If-Match, plus field-level merges for the records people edit most, cover a lot of ground.

Will a retried request create duplicates?

That depends on the HTTP method. Retries are normal in an offline-first app, because sometimes the request reaches the server and only the response is lost, and the app cannot tell whether it worked.

RFC 9110 calls a method idempotent if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request. PUT, DELETE and the safe methods qualify, and such requests can be repeated automatically if a communication failure occurs before the client is able to read the server's response. For anything else, such as POST, a client SHOULD NOT automatically retry unless it knows the request is idempotent anyway or that the original was never applied.

In practice that leaves two options:

  • Generate the id on the client and use PUT /tasks/<id>. Sending it five times still gives one task. Step 2 above does this.
  • If you must use POST /tasks, the server needs a way to recognise a repeat, for example a request id the client sends and the server remembers.

Without one of these, a retrying queue can create the same task several times on the server.

Which features should not be offline-first?

Anything that has to happen on the server right away and must not sit on the phone. Android's example is a bank transfer: an online-only write that goes over the network first and updates the local store only on success. While offline, the app should disable that control or tell the user it can't be done now (Android).

At the other end are analytics events and logs: it isn't essential that every one reaches the server, they aren't time sensitive, and nobody needs to be told when one fails. Android calls these queued writes. They are simpler than lazy writes because there is nothing to conflict with.

A short checklist before you design:

  1. Sort your data into three groups: online only, send later and don't care about the result, keep locally and never lose.
  2. Have the UI read from the local database only.
  3. Keep the queue on disk, send in order, retry with backoff.
  4. Use client-generated ids and idempotent methods.
  5. Pick a conflict rule per kind of data, and tell the user when the app chose for them.

Sources

Keep reading