System designDeep dive12 min
Offline-first & optimistic UI
An app that waits for the server on every tap shows a spinner in a tunnel and an error in a basement. An offline-first app reads and writes a copy on the device, shows every change at once, and syncs in the background. The work moves to the hard parts: replaying queued writes safely, merging edits made apart, and not losing data the browser is allowed to delete.
1 · The problem
An app that needs the network for every tap stops working where the work is
A building-inspection company equips its inspectors with a web app: a checklist of about 40 items per building, each marked pass or fail, with notes and photos. Noor starts the day's first inspection in a parking garage two levels underground. The app shows a spinner, then "Network error", and the first three answers are gone.
The app was built online-first: every screen fetched from the server and every tap was a request that had to succeed before the screen changed. An offline-first app turns that around. The device keeps its own copy of the data the user needs; the interface reads and writes that copy, and a sync process exchanges changes with the server whenever it can:
Aspect | Online-first | Offline-first |
|---|---|---|
The UI reads from | The server, on each screen | A local database on the device |
A tap waits for | A network round trip | A local write |
With no signal | Errors; work is lost | Works; changes queue up |
The hard part | Latency and error screens | Sync, conflicts and local storage limits |
The goal has a well-known statement in the local-first software movement: no spinners, and the network is optional. The device's copy is where the user works, and servers hold copies that let other devices and people see the same data. Some backends build it in; Firestore, for example, keeps an offline cache on Android and Apple devices by default, though on the web it has to be turned on.
2 · Local first
Read and write a local database, and treat the network as a sync channel
The offline-first inspection app has three local pieces and one remote one. The app's code and assets are cached on the device, so it opens with no signal. Inspection data lives in a local database. Every change also goes into an outbox, a local queue of changes not yet confirmed by the server:
The screen never waits for the server. The outbox makes the network a background detail: changes leave when there is a connection, and others' changes come back the same way.
In a browser, a service worker sits between the page and the network like a proxy, and can answer requests from its own cache; that's what lets the app shell load in the garage. The data goes in IndexedDB; a native app can use an on-device database such as SQLite.
Don't decide whether to sync by asking the browser if it's online. navigator.onLine reports a network connection, not internet access: a device on a local network with no route to the internet still counts as online. The reliable test is to try the request, and treat a timeout like any other failure. The Background Synchronization API can wake a service worker when connectivity returns, but it isn't available in all major browsers, so the app must also retry when it is opened.
3 · Optimistic UI
Show the result at once, then confirm it or roll it back
Even with a good signal, waiting 300 ms for each checkmark makes a 40-item checklist feel sluggish. An optimistic update shows the expected result immediately and sends the request in the background, on the assumption that it will usually succeed. When it doesn't, the interface has to undo the change and say why:
The screen changes before the server answers. A rejection restores the previous value and tells the user, rather than silently leaving the screen wrong.
Client libraries build this pattern in:
- React's
useOptimisticshows an optimistic value while an action is pending, and returns to the real value when the action ends. - TanStack Query saves a snapshot of the cached data before the mutation, restores it on error, and refetches when the request settles either way, so the screen ends up matching the server.
- Firestore notifies listeners of a local write before it reaches the backend, a feature it calls latency compensation, and marks those documents with
hasPendingWrites.
That last flag matters for honesty. An optimistic screen should still let the user tell "saved here" from "confirmed by the server": the inspection app shows a small pending badge on items still in the outbox, and a count of unsynced changes in the header. And not every action should be optimistic. The inspection app shows checkmarks and notes at once, but submitting a signed final report waits for the server, because telling a building owner it passed and then retracting it costs more than a second's wait.
4 · The outbox
Queued writes must be safe to send twice and in the right order
Back in the garage, Noor marks 25 items and takes 12 photos before reaching signal at street level. The outbox now holds 37 changes. A retry is certain: the connection may drop halfway, or a response may be lost after the server applied the change. So the replay has rules:
- Record locally, atomically
Write the change to the local database and the outbox in one local transaction, so neither exists without the other.
- Give each change an id
Each outbox entry carries a unique id that the server uses as an idempotency key, so a resend is recognized and applied once (Idempotency).
- Send in order
Send entries in the order they were made: creating the inspection before its items, and an edit before the later edit of the same item.
- Retry with backoff
On timeouts and server errors, retry with growing, jittered delays (Timeouts, retries, backoff & jitter); remove an entry only when the server confirms it.
- Separate rejections
A change the server refuses, such as an edit to a signed-off report, is not retried forever: it's marked as a conflict for the user to resolve.
Large files go separately. The 12 photos are uploaded as files in their own resumable queue (Resumable & chunked uploads), and the outbox entry for each item only references the photo's id, so a slow upload doesn't block the small changes behind it.
The server side has one more duty: telling the device what changed while it was away. A common shape is a change feed with a cursor. The device asks for everything after the last position it saw and stores the new position. It's the same idea as Change data capture, scoped to what this user may see.
5 · Conflicts
Edits made apart will collide, so choose the merge rule before it happens
While Noor is offline, a supervisor at the office edits the same building's "fire door" item from fail to pass with a note. Noor, still in the garage, adds a different note to the same item. Both changes are valid, and both arrive at the server. What happens next depends on a rule chosen in advance:
Rule | How it works | Who uses it | Risk |
|---|---|---|---|
Last write wins | The write applied last replaces the earlier one | Firestore, for multiple changes to one document | Silently drops an edit |
Keep both, pick a winner | A deterministic winner is shown; the losers are kept as conflicts | CouchDB, which lists them in _conflicts | Someone must resolve them |
Merge by data type | Data types designed so concurrent edits combine | CRDT libraries | Not every rule can be expressed |
Server decides | The server applies business rules and may reject | Most business apps, for some fields | The user sees a rejection later |
CouchDB shows the point of the second rule: the losing revisions aren't lost, only hidden, and an application that never looks at them has effectively lost them. Merging by data type is covered in CRDTs: data types that always merge.
Real apps mix rules per field. For the inspection app: notes are lists, so both notes are kept; the pass or fail result uses the server's rule, so a supervisor's decision overrides an inspector's earlier offline answer and the inspector is told; photos never conflict, because each has its own id. Avoid deciding by the device's clock: it is set by the device, not by you, so "latest timestamp wins" can let an older edit win (Time, clocks & ordering).
6 · Storage
The browser may delete local data, so unsynced work is at risk
A native app's database stays until the app is removed. A web app's doesn't have to. By default a browser stores a site's data as best-effort: it lasts while the site is under its quota and the device has space. Under storage pressure, browsers evict whole sites' data, least recently used first. A site can ask for persistent storage with navigator.storage.persist(), which only the user can clear; Firefox asks the user, while Safari and Chromium browsers decide from how the user has used the site.
The most one site may store. Firefox's best-effort limit is also capped by a 10 GiB group limit, and persistent storage at 8 TiB. Quotas are computed from total disk size, not free space, so a full disk runs out first.
Safari adds a rule that matters most for offline apps. Its tracking prevention deletes all of a website's script-writable storage, including IndexedDB, local storage and service worker caches, after seven days of Safari use without the user interacting with the site. Web apps added to the home screen keep their own count of days, based on their own use. Private browsing modes usually delete stored data when the session ends.
For the inspection app, the consequences are concrete:
- Sync early and often, so the outbox is rarely more than minutes old.
- Ask for persistent storage, and handle the quota error the browser throws when space runs out.
- Show unsynced work, and warn before the user signs out or clears the app with changes still queued.
- Treat the device copy as a cache plus an outbox, never as the only copy of confirmed data.
7 · The checklist
Offline-first moves the hard work from the screen to sync
For the inspection app, and most apps used where the signal comes and goes:
Decision | Inspection app choice |
|---|---|
Local data | Today's inspections in IndexedDB; app shell cached by a service worker |
Writes | Local database and outbox in one transaction |
What is optimistic | Checkmarks, notes and photos; not the signed final report |
Replay | In order, with idempotency keys, backoff, and rejections set aside |
Conflicts | Notes kept from both sides; results decided by the server; no device-clock wins |
Pulling changes | A change feed with a cursor per device |
Storage | Persistent storage requested; unsynced count visible; sign-out warns |
The cost is real: two copies of the data, a sync protocol, and conflict rules to design and test. It pays off where users work without a reliable connection, or where every interaction waiting on a round trip makes the product feel slow.
Check yourself
4 questions
Takeaways
Remember this
- Offline-first apps read and write a local database and sync in the background, so the network becomes optional.
- Optimistic updates show results at once but need rollback, a visible pending state, and judgment about which actions may be optimistic.
- A local outbox replays changes in order with idempotency keys and backoff, and sets aside rejections as conflicts.
- Concurrent offline edits need a merge rule chosen in advance; last write wins silently drops edits, and device clocks are not trustworthy tiebreakers.
- Browsers may evict web storage, Safari after seven days without interaction, so sync early and never keep the only copy on the device.