React Native offline-first: SQLite + sync that works
Offline-first is a 4-layer problem, not a feature flag. Here's the architecture we ship across Expo + React Native projects.
Offline-first is a 4-layer problem, not a feature flag. Here's the architecture we ship across Expo + React Native projects.
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.
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'
);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.
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`.
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]'.
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));
}
});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.

Founder, DField Solutions
I'm a full-stack engineer and I build across the whole stack myself · AI agents, web and mobile apps, blockchain, backends, security, right down to the OS layer. If it's software, I've probably built it and broken it.
Let's talk about your project. 30 minutes, no strings.