离线- First in React Industrial: 构建用户从未想到的自动同步引擎

2026年8月27日2 次浏览来源:Dev.to阅读原文

正文保留英文原文(机翻易破坏代码与排版),标题/摘要已提供中文

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 .

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.

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: 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. is data, not app state.

It survives restarts, it's queryable ( is your sync queue), and you can surface it in the UI as a per-record badge. lives 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 ordered by .

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: Notice what's missing: no , 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 badge on the record.

Step 3 — Connectivity: react to events, verify before flushing gives you connectivity events, but two gotchas matter in production: means "has a network interface", not "can reach your API".

Captive portals and dead corporate Wi-Fi will lie to you.

Use , 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. ⚠️ Hard-won lesson: Never gate the save on connectivity — only gate the flush.

The moment you write 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: 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 nudge to every moment connectivity or data might have changed: Trigger Source Why it matters After every local write 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 .

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 (UUID) in the payload — the same one on every retry.

Server: a unique constraint on and upsert semantics: "if I've seen this localId, return the previous ack instead of inserting." ⚠️ 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 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 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 / badge per record, plus a "3 records waiting to sync" strip, converts anxi

分享