Skip to content

An offline-first app survives subway tunnels, rural 2G, and airplane mode without the user noticing. That is not a React Query `staleTime` feature. It's a 4-layer architecture: local persistence, operation log, sync protocol, conflict resolver. Skip any one and the app feels broken.

Layer 1 · Local persistence (SQLite)

Expo SQLite or react-native-quick-sqlite (faster, unmounts cleanly). Persist the full domain model, not just the UI cache. The SQLite schema mirrors the server schema with two extra columns: `_local_updated_at`, `_sync_state` (synced / dirty / conflicted).

CREATE TABLE tasks (
  id TEXT PRIMARY KEY,
  title TEXT NOT NULL,
  completed INTEGER NOT NULL DEFAULT 0,
  updated_at INTEGER NOT NULL,
  _local_updated_at INTEGER NOT NULL,
  _sync_state TEXT NOT NULL DEFAULT 'synced'
);

Layer 2 · Operation log

Every user action writes an operation to a local queue: `{ op: 'update_task', payload: { id, fields }, client_id, ts }`. This log is what syncs to the server when online. Storing ops (not rows) preserves intent, which matters for conflict resolution.

Layer 3 · Sync protocol

Server accepts a batch of ops, returns (a) ops that succeeded with their server-assigned timestamps, (b) conflicts with the current server state, (c) any ops from other clients the caller missed. We implement this as a `POST /sync` endpoint that's idempotent by `client_id + ts`.

Layer 4 · Conflict resolver

Last-write-wins (LWW) is the default · server timestamp breaks ties. For fields that must never be overwritten silently (amounts, approvals), we implement CRDT-style operation merging. Mobile-side, a conflict surfaces as a banner: 'Server updated this task. Keep yours? [Keep] [Replace]'.

Background sync on iOS + Android

Expo BackgroundTask (iOS) and BackgroundFetch (Android) give us ~15-minute minimum periodicity. Trigger a sync on app foreground + on connectivity restore + on a periodic timer · three code paths, all hitting the same sync function.

import NetInfo from "@react-native-community/netinfo";
import { syncNow } from "./sync";

NetInfo.addEventListener((state) => {
  if (state.isConnected && state.isInternetReachable) {
    syncNow().catch((e) => console.warn("sync failed", e));
  }
});

Edge cases worth handling

  • User switches accounts · nuke the SQLite DB, not just the auth token.
  • Device clock is wrong · sync with a server-timestamped response, never trust client clocks for tie-breaking.
  • Long offline period · an op-log with 500+ queued ops sends fine but takes a noticeable moment; show a progress indicator.
  • Corrupt SQLite file · handle `PRAGMA integrity_check` on startup and fall back to re-download.
  • Large binary content (images, PDFs) · never in the op-log; upload to object storage, reference by URL.

Measure offline-mode success rate in production: how many actions succeeded without an immediate network round-trip? If it's below 95%, your app isn't offline-first · it's offline-tolerant.

ShareXLinkedIn#
Dezső Mező

By

Dezső Mező

Founder, DField Solutions

I've shipped production products from fintech to creator-tooling · for startups and enterprises, from Budapest to San Francisco.

Keep reading

RELATED PROJECTS

Would rather build together?

Let's talk about your project. 30 minutes, no strings.

Let's talk