By Shivkrishna Shah · Engineer Philosophy — @shivkrishnashah · @engineerphilosophy
Your app shouldn't have a "no internet" screen. Here's the architecture I use to make mobile apps write locally, sync automatically, and survive the messy reality of field connectivity.
Every mobile developer has shipped this screen at least once: a sad cloud icon and the words "No internet connection. Please try again."
For consumer apps, that's an annoyance. For enterprise field apps — sales reps in hospital basements, auditors in warehouses, technicians in rural areas — it's a dealbreaker. If the app stops working when the signal drops, people stop trusting it. And once field users stop trusting an app, they go back to paper and WhatsApp.
I spent the last few years building and maintaining an offline-first React Native platform used daily by field teams across multiple countries. This post is the architecture I wish someone had handed me on day one: how to structure local storage, detect connectivity, queue writes, auto-sync in the background, and avoid the two bugs that will absolutely bite you (duplicates and conflicts).
Everything here is generic — I'll use Realm DB and NetInfo in the examples, but the pattern maps cleanly onto WatermelonDB, SQLite, or MMKV-backed queues.
The one rule that changes everything
The local database is the source of truth. The server is just a replica you happen to reconcile with.
Most apps are built the other way around: the server is the truth, and the app is a thin cache over fetch(). Offline-first inverts this. Every read comes from the local DB. Every write goes to the local DB first. The network is an implementation detail that a background service worries about — never the UI.
This single inversion gives you three things for free:
- Zero-latency UX. Saves are instant because they're local writes. No spinners on submit.
- Airplane-mode parity. The app behaves identically online and offline, because the UI never talks to the network.
- Crash safety. Data is durable the moment the user taps "Save" — even if the app is killed a second later.
┌──────────────┐ write ┌───────────────────┐ reconcile ┌──────────────┐
│ UI/Screens │ ─────────► │ Local DB (Realm) │ ◄─────────► │ Sync Engine │
│ │ ◄───────── │ SOURCE OF TRUTH │ │ (background)│
└──────────────┘ live query└───────────────────┘ └──────┬───────┘
│ when online
The UI never touches the network. Ever. ┌──────▼───────┐
NetInfo ───────►│ REST API │
(wake / pause) │ (replica) │
└──────────────┘
Step 1 — Give every record a sync passport
Offline-first lives or dies on per-record sync metadata. Every table that can be written on-device carries the same extra fields:
// Every offline-writable schema carries the same sync metadata
const VisitLogSchema = {
name: 'VisitLog',
primaryKey: 'localId',
properties: {
localId: 'string', // UUID generated on-device
serverId: 'int?', // assigned by the server after first sync
// ...domain fields (accountId, notes, timestamp, ...)
syncStatus: 'string', // 'pending' | 'syncing' | 'synced' | 'failed'
updatedAt: 'date', // device clock, for conflict resolution
syncedAt: 'date?', // last successful server ack
retryCount: 'int', // exponential backoff counter
},
};
Three deliberate choices here:
- Client-generated primary keys (UUIDs). The device must be able to create records — and relate them to each other — without asking the server for an ID. The server ID arrives later and is stored alongside, never as the primary key.
-
syncStatusis data, not app state. It survives restarts, it's queryable (syncStatus == 'pending'is your sync queue), and you can surface it in the UI as a per-record badge. -
retryCountlives on the record. Backoff shouldn't reset because the user relaunched the app.
💡 Design note: You don't need a separate "outbox" table if your DB is queryable — the pending queue is simply a live query over syncStatus IN ('pending','failed') ordered by updatedAt. One source of truth, no queue/table drift.
Step 2 — The write path: local first, always
Every save in the app goes through one door. No screen ever calls the API directly on submit:
export function saveVisitLog(data) {
const realm = getRealm();
realm.write(() => {
realm.create('VisitLog', {
...data,
localId: uuid(),
syncStatus: 'pending', // ← queued by definition
updatedAt: new Date(),
retryCount: 0,
});
});
requestSync('write'); // nudge the engine — fire and forget
}
Notice what's missing: no await fetch(), no try/catch around a network call, no "are we online?" check. The save is complete the moment the Realm transaction commits. The UI can navigate away immediately and show a PENDING badge on the record.
User taps "Save" ──► Realm write (status: pending) ──┬──► UI updates instantly ✓
└╌╌► Sync engine nudged ╌╌► Server (eventually)
Step 3 — Connectivity: react to events, verify before flushing
@react-native-community/netinfo gives you connectivity events, but two gotchas matter in production:
-
isConnectedmeans "has a network interface", not "can reach your API". Captive portals and dead corporate Wi-Fi will lie to you. UseisInternetReachable, and treat even that as a hint. - Connectivity flaps. Walking through a building can fire a dozen transitions per minute — debounce before triggering a flush.
import NetInfo from '@react-native-community/netinfo';
let online = false;
NetInfo.addEventListener(state => {
const next = !!state.isConnected && state.isInternetReachable !== false;
if (next && !online) requestSync('network-restored'); // debounced inside
online = next;
});
⚠️ Hard-won lesson: Never gate the save on connectivity — only gate the flush. The moment you write if (online) api.post() else saveLocally() you have two write paths, and they will drift apart. One door: local write, then sync.
Step 4 — The sync engine: a state machine, not a loop
The engine is a single background service that drains the pending queue in batches. Every record moves through an explicit lifecycle:
batch picked 2xx + ack
┌─────────┐ ──────────► ┌─────────┐ ──────────► ┌──────────┐
│ PENDING │ │ SYNCING │ │ SYNCED ✓ │
└─────────┘ ◄╌╌╌╌╌╌╌╌╌╌ └─────────┘ ──────────► ┌──────────┐
▲ retry after error / timeout │ FAILED │
└╌╌ backoff: 2^retryCount × 30s (capped) └──────────┘
│ retryCount > N
▼ flag for support
async function flush() {
if (flushing || !online) return; // single-flight guard
flushing = true;
try {
const batch = realm.objects('VisitLog')
.filtered('syncStatus == "pending" AND nextRetryAt <= $0', new Date())
.sorted('updatedAt')
.slice(0, BATCH_SIZE); // e.g. 50 records per request
if (batch.length === 0) return;
markStatus(batch, 'syncing');
const res = await api.post('/sync', { records: serialize(batch) });
// Server acks per record — never all-or-nothing
realm.write(() => {
for (const ack of res.acks) {
const rec = realm.objectForPrimaryKey('VisitLog', ack.localId);
if (ack.ok) {
rec.serverId = ack.serverId;
rec.syncStatus = 'synced';
rec.syncedAt = new Date();
} else {
rec.syncStatus = 'failed';
rec.retryCount += 1;
rec.nextRetryAt = backoff(rec.retryCount); // 2^n × 30s, capped at 1h
}
}
});
if (moreRemaining()) requestSync('drain'); // keep draining
} finally {
flushing = false;
}
}
The details that matter more than they look:
- Single-flight guard. Multiple triggers (timer + network event + fresh write) must never produce two concurrent flushes. One boolean saves you from the nastiest class of duplicate bug.
- Batching. Field users come back from a no-signal day with hundreds of pending records. One request per record melts your server and their battery; one giant request times out on 2G. Batch (~50) and drain iteratively.
- Per-record acks. If record 37 of 50 fails validation, the other 49 must still succeed. All-or-nothing batches turn one bad record into a permanently stuck queue.
Step 5 — Auto-sync: triggers, not polling
"Auto" sync is just wiring the same requestSync() nudge to every moment connectivity or data might have changed:
| Trigger | Source | Why it matters |
|---|---|---|
| After every local write |
saveX() helpers |
Online users sync within seconds — feels real-time |
| Network restored | NetInfo listener (debounced) | The classic "walked out of the basement" moment |
| App → foreground | AppState listener | OS may have suspended timers while backgrounded |
| Periodic timer | ~every 2–5 min while active | Safety net for missed events; also pulls server changes down |
| Manual pull-to-refresh | User | Trust: users want a button even if they never need it |
All five funnel into one debounced entry point — which is exactly why the single-flight guard in Step 4 exists.
Step 6 — The duplicate problem (this one will get you)
Here's the failure sequence that produces duplicate records in every naive sync implementation:
- Device sends batch → server inserts the rows…
- …but the response is lost (timeout, tunnel, app killed mid-request).
- Device never got the ack → records stay
PENDING. - Next flush re-sends them → server inserts them again.
The network being unreliable in both directions is the whole premise of offline-first, so retries are guaranteed. The fix is idempotency, enforced on both sides:
-
Client: every record carries its device-generated
localId(UUID) in the payload — the same one on every retry. -
Server: a unique constraint on
(device_id, local_id)and upsert semantics: "if I've seen this localId, return the previous ack instead of inserting."
-- Server-side: retries become no-ops, not duplicates
INSERT INTO visit_logs (local_id, device_id, ...)
VALUES (?, ?, ...)
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id); -- return existing row's id
⚠️ Also on the client: If you have two possible senders — say, a foreground "submit now" path and a background flush service — they can race and double-send the same rows before either ack lands. Either collapse them into one sender, or add a mutex so only one path can flush a given record type at a time. We learned this from a production duplicate-insert bug that only reproduced on slow networks.
Step 7 — Conflicts: pick a policy before you need one
Downstream sync (server → device) eventually meets a record edited in both places. There is no universally correct answer — there is only a policy chosen per table, on purpose:
| Strategy | Rule | Use for |
|---|---|---|
| Last-write-wins | Higher updatedAt wins |
Single-owner data (a rep's own notes) — simple, predictable |
| Server-wins | Server copy always replaces local | Reference/master data the device merely displays |
| Client-wins | Device copy survives until explicitly synced | In-progress work the user is actively editing |
| Field-level merge | Compare per column, merge non-overlapping edits | High-value shared records — costs real complexity |
| Manual resolution | Park both versions, ask a human | Rare, high-stakes conflicts (approvals, financial data) |
In a field-team context, last-write-wins with per-record ownership covers ~90% of cases, because most offline-written records have exactly one author — the device that created them. Design your data model so this stays true and you may never need the expensive strategies.
💡 Clock warning: LWW compares timestamps, and device clocks lie. Record the device's updatedAt but let the server stamp arrival time and sanity-check drift (e.g., reject client timestamps from the future). Never resolve conflicts with unvetted device time alone.
What I'd tell you before you build it
-
Show sync state honestly. A tiny
PENDING/SYNCEDbadge per record, plus a "3 records waiting to sync" strip, converts anxiety into trust. Users forgive a delayed sync; they never forgive silent data loss. - Sync your error logs too. When a device misbehaves offline, the evidence is trapped on it. Treat error logs as just another offline-writable table that flushes with everything else — production debugging becomes possible again.
- Test with the network chaos monkey. Airplane mode mid-flush, killed app mid-request, captive-portal Wi-Fi, 48 hours offline then 400 pending records. Every one of these is a Tuesday for a field user.
- Never let a stuck record block the queue. Cap retries, quarantine poison records, flag them for support — and keep draining the rest.
- Design the server API for sync from day one. Batched upserts, per-record acks, idempotency keys. Retrofitting idempotency onto a plain CRUD API is far more painful than building it in.
The payoff
The architecture above is maybe 500 lines of engine code plus a schema convention — small enough to own completely, with no black-box sync SaaS in the middle. What it buys you:
- Saves that complete in milliseconds, every time, regardless of signal.
- A field workforce that can work a full offline day and reconcile automatically over lunch Wi-Fi.
- Zero duplicate submissions — idempotency by construction, not by luck.
- One write path, one queue, one state machine: an architecture a new team member can hold in their head.
Offline-first isn't a feature you add. It's a decision about where the truth lives — and everything else follows from it.
If you're building something similar — or wrestling with sync bugs in production — I'm happy to compare notes. Drop a comment or reach out.
#ReactNative #OfflineFirst #MobileArchitecture #RealmDB #SyncEngine #MobileDevelopment
Top comments (0)