How to build mobile apps that work in tunnels and dead zones, then sync cleanly when the network returns.
By Kibet Brian
Users do not care about your network layer. They open the app in a basement parking lot and expect their data to be there. Offline-first means the local database is the source of truth and the server is a sync peer, not a dependency.
Every record gets a client-generated id, an updatedAt timestamp, and a syncStatus of clean, dirty, or conflicted. The UI only ever reads from SQLite, so renders are instant and never gated on fetch.
1CREATE TABLE notes (2 id TEXT PRIMARY KEY,3 body TEXT NOT NULL,4 updated_at INTEGER NOT NULL,5 sync_status TEXT NOT NULL DEFAULT 'dirty'6};78CREATE INDEX idx_notes_sync ON notes (sync_status, updated_at);
Writes go to SQLite first and enqueue a sync job. A background task drains the queue oldest first, pushes dirty rows, pulls server changes since the last cursor, and marks rows clean. If the network drops mid-drain, the queue simply waits, nothing is lost.
1async function drainQueue() {2 const dirty = await db.getDirtyRows();3 for (const row of dirty) {4 await api.push(row);5 await db.markClean(row.id);6 }7 const cursor = await db.getCursor();8 const remote = await api.pull({ since: cursor });9 await db.applyRemote(remote);10}
Conflicts are rare if you sync often, but they need a deterministic rule. Last-writer-wins by timestamp covers most consumer cases. For collaborative fields, merge at the field level and keep a log so support can explain any outcome.
If your app shows a spinner on open, you built an online app with an offline error message.
Polling for connectivity wastes battery and still misses the moment. Listen to network state changes and drain the queue the instant a usable connection returns, with exponential backoff when the network flaps.
If all four pass without lost or duplicated data, the offline story is solid. Everything else is polish on top of a foundation users can feel every time they open the app underground.