Start from the local store
The core decision in an offline-first architecture is where the interface gets its data. Android’s guide to building an offline-first app puts it plainly: the local data source is the canonical source of truth for the app, and it should be the only source that higher layers read from. The network is a second data source that a repository uses to update the local one.
This gives a simple rule for every screen. It observes local data and redraws when that data changes, whether the change came from the person’s own action or from a sync that just finished. No screen calls the network directly, so no screen has an error state that only exists because a request failed.
The local store is usually an embedded database such as SQLite, or IndexedDB in a browser. Android’s guide also recommends keeping separate models for what the network sends and what the database stores, so a change to the server’s format does not spread through the app.
Decide what lives on the device
A device cannot hold everything, so decide what each kind of person needs while disconnected. A technician needs today’s jobs and their manuals; a reader needs the articles they opened. The scope determines how sync fetches data.
Android describes two approaches. In pull-based sync the app fetches data when a screen is about to show it, which is simple but leaves screens empty or stale after a long time offline. In push-based sync the app downloads a baseline on first start and then fetches only what the server says has changed, which lets it stay offline far longer but needs a server that supports synchronization. Many apps mix the two by kind of data. How does offline sync work? covers what happens when the copies meet.
Choose a write strategy per action
Writes need more thought than reads, because a write made offline can be rejected later. Android names three strategies:
- Online-only writes. Try the network first and update the local store only on success. Its example is a bank transfer. When offline, disable the action or say clearly that it needs a connection.
- Queued writes. Put the write in a queue and send it later, accepting that it may never arrive. Suitable for analytics events and logging.
- Lazy writes. Write to the local store first, then queue the change for the network. This is the right choice for data the person cannot afford to lose, such as tasks in a to-do app, and it is where conflicts come from.
Lazy writes mean the screen shows a change before any server has seen it, which is an optimistic update. The queue of pending changes is an outbox. Because the app will retry sends whose outcome it never heard, each change needs a stable identifier so the receiver can apply it once; this is idempotency.
Plan sync, retries, and conflicts
The sync layer drains queues when a connection exists and retries with growing delays when it fails. Each platform has tools for this:
- Android. WorkManager runs persistent work with a network constraint and retries failed syncs with exponential backoff.
- Apple platforms. A URLSession configured to wait for connectivity holds a request until a connection is available instead of failing at once, and background sessions always wait.
- Web. A service worker can answer requests from a cache, following patterns such as cache falling back to network or stale-while-revalidate. The Background Synchronization API lets a page defer requests until the network returns, but MDN marks it as not Baseline because it does not work in some widely used browsers.
When two copies change the same data apart, the app needs a rule decided in advance. Android notes that last write wins is common on mobile; other data is better merged with a CRDT or sent to a person. How are sync conflicts resolved? compares the options.
Keep an authority and show the truth
Offline-first does not remove the server’s role. Sync makes copies agree, but it cannot check that stock is not claimed twice or that a person was allowed to approve something. Those rules need an authority, usually the backend. Offline Protocol’s documentation describes the same split for device-to-device work: the system of record remains authoritative for the records it owns, and the app should define when the backend has durably accepted a record before clearing its local queue.
The interface should match. Show what is saved on this device, what is waiting to send, and what the server has confirmed, and give people a way forward when a queued change is rejected. Delivered, accepted, committed explains why these are different moments.
Design the tests with the architecture
Each decision above creates a case to test: a cold start with no network, a write queued for days, a send that drops halfway, a conflict, a rejection. Write these down while designing, not after. How do you test an app for offline behaviour? lists the tools and scenarios.