Users don't care whether they're on a train, in a lift or in a building with terrible signal. They expect your app to work. Offline-first design treats the network as optional: the app reads and writes locally, then syncs when it can.
Here's a pattern that works well in practice.
1. The local database is the source of truth for the UI
The UI never waits on the network. It reads from and writes to a local database (SQLite, Realm, WatermelonDB, Drift and so on). Network responses update the local database, and the UI reacts to those changes.
This alone makes an app feel dramatically faster, even online.
2. Queue every change in an "outbox"
When the user makes a change, write it locally and add an entry to an outbox table in the same transaction:
type OutboxItem = {
id: string; // unique ID, generated on the device
entity: "task";
action: "create" | "update" | "delete";
payload: unknown;
createdAt: number;
attempts: number;
};
A background sync worker then processes the outbox in order:
async function flushOutbox() {
const items = await db.outbox.orderBy("createdAt").toArray();
for (const item of items) {
try {
await api.send(item, { idempotencyKey: item.id });
await db.outbox.delete(item.id);
} catch (err) {
await db.outbox.update(item.id, { attempts: item.attempts + 1 });
break; // keep ordering; retry later with backoff
}
}
}
Trigger it when connectivity returns, when the app comes to the foreground, and on a timer.
3. Make requests idempotent
Mobile networks fail in awkward ways. A request can succeed on the server while the response never reaches the phone, so the app retries. Without protection, that creates duplicates.
Send the outbox item's ID as an idempotency key. The server stores keys it has already processed and returns the original result for repeats instead of applying the change twice.
4. Decide on a conflict strategy up front
If two devices edit the same record offline, something has to give. Common options:
- Last write wins. Simple, but can silently discard edits.
- Field-level merge. Only fields that were actually changed get overwritten. This suits forms and profiles well.
- Ask the user. Best for high-value data like documents or orders.
Whatever you choose, store an updatedAt or version number on each record so conflicts can be detected rather than guessed.
5. Show sync status honestly
A small "Saved on device, syncing…" indicator builds far more trust than pretending everything reached the server. Surface failed syncs clearly so users aren't surprised later.
Quick checklist
- [ ] UI reads only from the local database
- [ ] Writes and outbox entries happen in one transaction
- [ ] Retries use exponential backoff
- [ ] Every request carries an idempotency key
- [ ] Conflict strategy is documented and tested
- [ ] Sync status is visible to the user
Offline-first takes more thought up front, but it pays off in reliability and user trust. Have you built offline sync before? What caught you out?
I work at MACROGEN, where we build iOS and Android apps. Some of our mobile work is here.
This article was created with the help of AI and reviewed for accuracy by the author.
Top comments (0)