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











