Offline-First
React Native offline-first — local databases, sync engines, conflict resolution, optimistic updates
Offline-First
Offline-first means the app works without a network connection as the default mode, not as a graceful degradation. The local database is the source of truth for reads; the server is the source of truth for conflict resolution. Everything in between is sync infrastructure.
Why Offline-First
Mobile networks are unreliable by nature. Elevators, subways, rural areas, airplane mode, flaky Wi-Fi behind captive portals -- these are not edge cases but daily conditions for a significant share of your users.
| Approach | Behavior offline | Perceived latency | Complexity |
|---|---|---|---|
| Online-only | Spinner, then error | Network RTT on every action | Low |
| Cache-then-network | Stale data if cached, error if not | Fast on cache hit | Medium |
| Offline-first | Full read/write capability | Near-zero (local reads) | High |
The payoff is not just offline access. Even on a fast connection, reading from a local database eliminates network latency from the render path. Users perceive the app as instant. Writes feel immediate because they hit local storage first and sync in the background.
Architecture Choices That Block Offline
- Fetching data only on screen focus with no local cache.
- Storing state exclusively in memory (lost on app kill).
- Server-generated IDs required before any client-side state can reference a record.
- Pagination APIs that require a live server cursor.
Architecture Choices That Enable Offline
- Client-generated UUIDs for all records.
- Local database as the read layer; network as a sync transport.
- Idempotent API endpoints that tolerate duplicate writes.
- Timestamps or vector clocks on every mutable record.
Local Database Selection
| Library | Query Capability | Reactive Updates | Sync Support | Performance | Encryption | Best For |
|---|---|---|---|---|---|---|
| WatermelonDB | SQL (SQLite under the hood) | Built-in observable queries | Built-in sync protocol | Lazy-loading, very fast for large datasets | Via SQLCipher fork | Apps with thousands of records and complex relations |
| Realm | Object query language | Live objects auto-update | Realm Sync (Atlas Device Sync) | Native C++ engine, fast | Built-in | Teams using MongoDB Atlas backend |
| expo-sqlite | Raw SQL | Manual (re-query on change) | Manual | Good, uses platform SQLite | No | Expo projects needing direct SQL control |
| op-sqlite | Raw SQL | Manual | Manual | Fastest SQLite binding (JSI) | Via SQLCipher | Performance-critical bare RN projects |
| MMKV | Key-value only | Manual listeners | Manual | Extremely fast for small values | Built-in | Preferences, tokens, small serialized state |
| AsyncStorage | Key-value only | None | None | Slow (async bridge, JSON serialize) | No | Legacy projects only |
AsyncStorage is not a database. It serializes everything to JSON strings, has no indexing, no transactions, and poor performance past a few hundred keys. Use it only when migrating legacy code. For new projects, MMKV replaces it for key-value storage, and a real database replaces it for structured data.
Decision Tree
What kind of data?
├── Key-value (tokens, preferences, flags)
│ └── MMKV
├── Structured records (< 500 rows, simple queries)
│ ├── Expo project → expo-sqlite
│ └── Bare RN → op-sqlite
├── Structured records (thousands of rows, relations, reactive UI)
│ ├── Need built-in sync protocol → WatermelonDB
│ ├── Using MongoDB Atlas → Realm
│ └── Otherwise → WatermelonDB (most flexible)
└── Binary blobs (images, files)
└── File system (expo-file-system / react-native-fs) + DB for metadataWatermelonDB Setup
// database/schema.ts
import { appSchema, tableSchema } from '@nozbe/watermelondb';
export const schema = appSchema({
version: 1,
tables: [
tableSchema({
name: 'tasks',
columns: [
{ name: 'title', type: 'string' },
{ name: 'body', type: 'string', isOptional: true },
{ name: 'is_completed', type: 'boolean' },
{ name: 'project_id', type: 'string', isIndexed: true },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
tableSchema({
name: 'projects',
columns: [
{ name: 'name', type: 'string' },
{ name: 'color', type: 'string' },
],
}),
],
});// database/models/Task.ts
import { Model, field, relation, writer } from '@nozbe/watermelondb';
import { Associations } from '@nozbe/watermelondb/Model';
export class Task extends Model {
static table = 'tasks';
static associations: Associations = {
projects: { type: 'belongs_to', key: 'project_id' },
};
@field('title') title!: string;
@field('body') body!: string;
@field('is_completed') isCompleted!: boolean;
@field('project_id') projectId!: string;
@field('created_at') createdAt!: number;
@field('updated_at') updatedAt!: number;
@relation('projects', 'project_id') project!: any;
@writer async markComplete() {
await this.update((task) => {
task.isCompleted = true;
task.updatedAt = Date.now();
});
}
}// database/index.ts
import { Database } from '@nozbe/watermelondb';
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';
import { schema } from './schema';
import { Task } from './models/Task';
const adapter = new SQLiteAdapter({
schema,
jsi: true, // use JSI for synchronous native calls — significant perf gain
onSetUpError: (error) => {
// Log to crash reporting; consider database reset as last resort
console.error('Database setup failed', error);
},
});
export const database = new Database({
adapter,
modelClasses: [Task],
});// features/tasks/components/TaskList.tsx
import { withObservables } from '@nozbe/watermelondb/react';
import { database } from '../../../database';
import { Q } from '@nozbe/watermelondb';
function TaskList({ tasks }: { tasks: Task[] }) {
return (
<FlatList
data={tasks}
keyExtractor={(t) => t.id}
renderItem={({ item }) => <TaskRow task={item} />}
/>
);
}
const enhance = withObservables([], () => ({
tasks: database
.get<Task>('tasks')
.query(Q.where('is_completed', false), Q.sortBy('created_at', Q.desc))
.observe(),
}));
export default enhance(TaskList);Offline-First Architecture Patterns
Local-First Read
Always read from the local database. The network is a sync transport, not a data source for the UI layer.
User taps screen
→ Query local DB (instant)
→ Render immediately
→ Background: sync with server
→ If new data arrived → DB update → reactive query fires → UI updatesThis eliminates loading spinners for cached data and makes the app feel instant regardless of network conditions.
Write-Through with Operation Queue
Writes go to the local database immediately. A background queue picks them up and pushes to the server.
// core/sync/operationQueue.ts
import { MMKV } from 'react-native-mmkv';
interface PendingOperation {
id: string;
type: 'create' | 'update' | 'delete';
table: string;
recordId: string;
payload: Record<string, unknown>;
createdAt: number;
retryCount: number;
}
const storage = new MMKV({ id: 'operation-queue' });
export function enqueue(op: Omit<PendingOperation, 'id' | 'createdAt' | 'retryCount'>) {
const operations = getAll();
const entry: PendingOperation = {
...op,
id: crypto.randomUUID(),
createdAt: Date.now(),
retryCount: 0,
};
operations.push(entry);
storage.set('ops', JSON.stringify(operations));
}
export function getAll(): PendingOperation[] {
const raw = storage.getString('ops');
return raw ? JSON.parse(raw) : [];
}
export function remove(id: string) {
const ops = getAll().filter((o) => o.id !== id);
storage.set('ops', JSON.stringify(ops));
}TanStack Query Offline Support
TanStack Query has first-class offline support through networkMode and persistQueryClient.
// app/providers/OfflineQuery.tsx
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { MMKV } from 'react-native-mmkv';
import NetInfo from '@react-native-community/netinfo';
import { onlineManager, focusManager } from '@tanstack/react-query';
import { AppState } from 'react-native';
// Wire up online/offline detection
NetInfo.addEventListener((state) => {
onlineManager.setOnline(!!state.isConnected);
});
// Refetch on app foreground
AppState.addEventListener('change', (status) => {
focusManager.setFocused(status === 'active');
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
networkMode: 'offlineFirst', // serve from cache, then refetch
staleTime: 5 * 60_000,
gcTime: 24 * 60 * 60_000, // keep cache for 24h
retry: 3,
},
mutations: {
networkMode: 'offlineFirst',
retry: 3,
},
},
});
// MMKV-backed persister
const mmkv = new MMKV({ id: 'query-cache' });
const persister = createSyncStoragePersister({
storage: {
getItem: (k) => mmkv.getString(k) ?? null,
setItem: (k, v) => mmkv.set(k, v),
removeItem: (k) => mmkv.delete(k),
},
});
export function OfflineQueryProvider({ children }: { children: React.ReactNode }) {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister, maxAge: 24 * 60 * 60_000 }}
>
{children}
</PersistQueryClientProvider>
);
}Repository Pattern with Offline Support
A repository abstracts whether data comes from the local DB, the network, or both.
// features/tasks/repository.ts
import { database } from '../../database';
import { Task } from '../../database/models/Task';
import { Q } from '@nozbe/watermelondb';
import { api } from '../../core/api';
import { enqueue } from '../../core/sync/operationQueue';
export const taskRepository = {
async getByProject(projectId: string): Promise<Task[]> {
return database
.get<Task>('tasks')
.query(Q.where('project_id', projectId))
.fetch();
},
async create(data: { title: string; projectId: string }): Promise<Task> {
const task = await database.write(async () => {
return database.get<Task>('tasks').create((t) => {
t.title = data.title;
t.projectId = data.projectId;
t.isCompleted = false;
t.createdAt = Date.now();
t.updatedAt = Date.now();
});
});
// Queue for server sync
enqueue({
type: 'create',
table: 'tasks',
recordId: task.id,
payload: { title: data.title, project_id: data.projectId },
});
return task;
},
};Connectivity Detection
// core/hooks/useNetworkStatus.ts
import { useEffect, useState } from 'react';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
export function useNetworkStatus() {
const [isConnected, setIsConnected] = useState(true);
const [connectionType, setConnectionType] = useState<string | null>(null);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
setIsConnected(!!state.isConnected && !!state.isInternetReachable);
setConnectionType(state.type);
});
return unsubscribe;
}, []);
return { isConnected, connectionType };
}isConnected alone is not enough. A device can be connected to Wi-Fi but have no internet access (captive portals, firewalled networks). Check isInternetReachable as well, or better yet, design your app to handle request failures gracefully regardless of what NetInfo reports.
Optimistic Updates
Optimistic updates show the result of a mutation immediately, before the server confirms it. If the server rejects the mutation, the UI rolls back.
TanStack Query Optimistic Updates
// features/tasks/hooks/useToggleTask.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../../../core/api';
interface Task {
id: string;
title: string;
isCompleted: boolean;
}
export function useToggleTask() {
const qc = useQueryClient();
return useMutation({
mutationFn: (task: Task) =>
api.patch(`/tasks/${task.id}`, { isCompleted: !task.isCompleted }),
onMutate: async (task) => {
// Cancel in-flight queries so they don't overwrite our optimistic update
await qc.cancelQueries({ queryKey: ['tasks'] });
// Snapshot current state for rollback
const previous = qc.getQueryData<Task[]>(['tasks']);
// Optimistically update
qc.setQueryData<Task[]>(['tasks'], (old) =>
old?.map((t) =>
t.id === task.id ? { ...t, isCompleted: !t.isCompleted } : t,
),
);
return { previous };
},
onError: (_err, _task, context) => {
// Rollback to snapshot
if (context?.previous) {
qc.setQueryData(['tasks'], context.previous);
}
},
onSettled: () => {
// Refetch to ensure server state is in sync
qc.invalidateQueries({ queryKey: ['tasks'] });
},
});
}Zustand Optimistic Pattern
// features/tasks/store.ts
import { create } from 'zustand';
import { api } from '../../core/api';
interface TaskStore {
tasks: Task[];
toggleTask: (id: string) => Promise<void>;
}
export const useTaskStore = create<TaskStore>((set, get) => ({
tasks: [],
toggleTask: async (id: string) => {
const original = get().tasks;
// Optimistic update
set({
tasks: original.map((t) =>
t.id === id ? { ...t, isCompleted: !t.isCompleted } : t,
),
});
try {
const task = original.find((t) => t.id === id)!;
await api.patch(`/tasks/${id}`, { isCompleted: !task.isCompleted });
} catch {
// Rollback
set({ tasks: original });
}
},
}));Conflict Resolution
When two clients modify the same record offline, a conflict exists. The sync protocol must resolve it.
Strategies
| Strategy | How it works | Trade-off |
|---|---|---|
| Last-write-wins (LWW) | Compare updatedAt timestamps; latest write survives | Simple but loses data silently |
| Server-wins | Server state always overwrites client | Safe but frustrating for the user who made changes offline |
| Client-wins | Client state always overwrites server | Dangerous if multiple clients exist |
| Field-level merge | Merge non-conflicting fields; flag true conflicts for manual resolution | Most correct but most complex |
| CRDTs | Conflict-free replicated data types (counters, sets, registers) | Mathematically guaranteed no conflicts; complex to implement |
LWW is fine for most apps. Unless your users actively collaborate on the same records simultaneously, last-write-wins with per-field timestamps covers 90% of cases. Do not over-engineer conflict resolution until you have evidence of real conflicts in production.
WatermelonDB Sync Protocol
WatermelonDB ships with a defined sync protocol based on pullChanges and pushChanges. The server must implement a compatible endpoint.
// core/sync/watermelonSync.ts
import { synchronize } from '@nozbe/watermelondb/sync';
import { database } from '../../database';
import { api } from '../api';
export async function syncDatabase() {
await synchronize({
database,
pullChanges: async ({ lastPulledAt, schemaVersion, migration }) => {
const response = await api.get('/sync/pull', {
params: {
last_pulled_at: lastPulledAt,
schema_version: schemaVersion,
migration: migration ? JSON.stringify(migration) : null,
},
});
const { changes, timestamp } = response.data;
return { changes, timestamp };
},
pushChanges: async ({ changes, lastPulledAt }) => {
await api.post('/sync/push', {
changes,
last_pulled_at: lastPulledAt,
});
},
migrationsEnabledAtVersion: 1,
sendCreatedAsUpdated: true, // simplifies server logic
});
}The changes object has this shape per table:
interface SyncChanges {
[tableName: string]: {
created: RawRecord[];
updated: RawRecord[];
deleted: string[]; // IDs only
};
}Server-Side Sync Endpoint (Simplified)
// server: GET /sync/pull
async function handlePull(req: Request) {
const lastPulledAt = Number(req.query.last_pulled_at) || 0;
const now = Date.now();
// Fetch all records modified since last pull
const tasks = await db.tasks.find({
updatedAt: { $gt: new Date(lastPulledAt) },
});
const deletedTasks = await db.deletedRecords.find({
table: 'tasks',
deletedAt: { $gt: new Date(lastPulledAt) },
});
return {
changes: {
tasks: {
created: tasks.filter((t) => t.createdAt > lastPulledAt),
updated: tasks.filter((t) => t.createdAt <= lastPulledAt),
deleted: deletedTasks.map((d) => d.recordId),
},
},
timestamp: now,
};
}Sync Engine Design
Full Sync vs Incremental Sync
| Approach | When to use | Trade-off |
|---|---|---|
| Full sync | Initial load, schema migration, data corruption recovery | Transfers everything; expensive on large datasets |
| Incremental sync | Every subsequent sync after initial | Only transfers changes since last sync cursor; fast |
Sync Cursor / Watermark Pattern
Every sync stores a cursor (usually a server timestamp) marking when data was last pulled. The next sync request passes this cursor so the server returns only newer changes.
Client sends: GET /sync?since=1700000000000
Server returns: { changes: [...], cursor: 1700001000000 }
Client stores cursor: 1700001000000
Next sync sends: GET /sync?since=1700001000000Use the server's clock, not the client's. Client clocks drift, can be set manually, and vary between devices. The sync cursor must come from the server response.
Custom Sync Engine
// core/sync/SyncEngine.ts
import NetInfo from '@react-native-community/netinfo';
import { MMKV } from 'react-native-mmkv';
interface SyncOperation {
id: string;
table: string;
type: 'create' | 'update' | 'delete';
payload: Record<string, unknown>;
createdAt: number;
retryCount: number;
}
const storage = new MMKV({ id: 'sync-engine' });
const MAX_RETRIES = 5;
const BATCH_SIZE = 50;
export class SyncEngine {
private isSyncing = false;
async push(): Promise<void> {
if (this.isSyncing) return;
this.isSyncing = true;
try {
const state = await NetInfo.fetch();
if (!state.isConnected) return;
const queue = this.getQueue();
const batches = this.toBatches(queue, BATCH_SIZE);
for (const batch of batches) {
try {
// Idempotent: server uses operation ID for deduplication
await api.post('/sync/push', { operations: batch });
// Remove successfully synced operations
for (const op of batch) {
this.removeFromQueue(op.id);
}
} catch (error) {
// Increment retry count; move to dead letter queue if exhausted
for (const op of batch) {
if (op.retryCount >= MAX_RETRIES) {
this.moveToDeadLetterQueue(op);
this.removeFromQueue(op.id);
} else {
this.incrementRetry(op.id);
}
}
}
}
} finally {
this.isSyncing = false;
}
}
async pull(): Promise<void> {
const cursor = storage.getNumber('sync-cursor') ?? 0;
const response = await api.get('/sync/pull', { params: { since: cursor } });
await database.write(async () => {
for (const change of response.data.changes) {
await this.applyChange(change);
}
});
storage.set('sync-cursor', response.data.cursor);
}
async fullSync(): Promise<void> {
await this.push();
await this.pull();
}
private getQueue(): SyncOperation[] {
const raw = storage.getString('queue');
return raw ? JSON.parse(raw) : [];
}
private toBatches<T>(items: T[], size: number): T[][] {
const batches: T[][] = [];
for (let i = 0; i < items.length; i += size) {
batches.push(items.slice(i, i + size));
}
return batches;
}
private removeFromQueue(id: string) {
const queue = this.getQueue().filter((op) => op.id !== id);
storage.set('queue', JSON.stringify(queue));
}
private incrementRetry(id: string) {
const queue = this.getQueue().map((op) =>
op.id === id ? { ...op, retryCount: op.retryCount + 1 } : op,
);
storage.set('queue', JSON.stringify(queue));
}
private moveToDeadLetterQueue(op: SyncOperation) {
const dlq = JSON.parse(storage.getString('dead-letter-queue') ?? '[]');
dlq.push({ ...op, failedAt: Date.now() });
storage.set('dead-letter-queue', JSON.stringify(dlq));
}
private async applyChange(change: {
table: string;
type: 'create' | 'update' | 'delete';
record: Record<string, unknown>;
}) {
const collection = database.get(change.table);
switch (change.type) {
case 'create':
await collection.create((rec: any) => {
Object.assign(rec._raw, change.record);
});
break;
case 'update': {
const existing = await collection.find(change.record.id as string);
await existing.update((rec: any) => {
Object.assign(rec._raw, change.record);
});
break;
}
case 'delete': {
const toDelete = await collection.find(change.record.id as string);
await toDelete.markAsDeleted();
break;
}
}
}
}
export const syncEngine = new SyncEngine();Sync Status Tracking
Expose sync status to the UI so users know when data is fresh, syncing, or stale.
// core/sync/useSyncStatus.ts
import { create } from 'zustand';
type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline';
interface SyncStatusState {
status: SyncStatus;
lastSyncedAt: number | null;
error: string | null;
setStatus: (status: SyncStatus) => void;
setSynced: () => void;
setError: (error: string) => void;
}
export const useSyncStatus = create<SyncStatusState>((set) => ({
status: 'idle',
lastSyncedAt: null,
error: null,
setStatus: (status) => set({ status, error: null }),
setSynced: () => set({ status: 'idle', lastSyncedAt: Date.now(), error: null }),
setError: (error) => set({ status: 'error', error }),
}));// shared/components/SyncIndicator.tsx
import { useSyncStatus } from '../../core/sync/useSyncStatus';
import { useNetworkStatus } from '../../core/hooks/useNetworkStatus';
export function SyncIndicator() {
const { status, lastSyncedAt } = useSyncStatus();
const { isConnected } = useNetworkStatus();
const label = !isConnected
? 'Offline'
: status === 'syncing'
? 'Syncing...'
: status === 'error'
? 'Sync failed'
: lastSyncedAt
? `Synced ${formatRelative(lastSyncedAt)}`
: 'Not synced';
return (
<View style={styles.container}>
<View style={[styles.dot, { backgroundColor: dotColor(status, isConnected) }]} />
<Text style={styles.label}>{label}</Text>
</View>
);
}Background Sync
Mobile OSes aggressively kill background processes. Background sync must work within tight platform constraints.
react-native-background-fetch
// core/sync/backgroundSync.ts
import BackgroundFetch from 'react-native-background-fetch';
import { syncEngine } from './SyncEngine';
import { useSyncStatus } from './useSyncStatus';
export async function configureBackgroundSync() {
const status = await BackgroundFetch.configure(
{
minimumFetchInterval: 15, // minutes (iOS minimum)
stopOnTerminate: false, // Android: continue after app is terminated
startOnBoot: true, // Android: start on device boot
enableHeadless: true, // Android: run without UI
requiredNetworkType: BackgroundFetch.NETWORK_TYPE_ANY,
},
async (taskId) => {
try {
useSyncStatus.getState().setStatus('syncing');
await syncEngine.fullSync();
useSyncStatus.getState().setSynced();
} catch (error) {
useSyncStatus.getState().setError(String(error));
} finally {
BackgroundFetch.finish(taskId);
}
},
(taskId) => {
// OS timed out the task
BackgroundFetch.finish(taskId);
},
);
console.log('[BackgroundSync] configure status:', status);
}
// Android headless task (runs without React context)
BackgroundFetch.registerHeadlessTask(async ({ taskId }) => {
await syncEngine.fullSync();
BackgroundFetch.finish(taskId);
});Platform Limitations
| Platform | Constraint | Implication |
|---|---|---|
| iOS | Minimum 15-minute interval; OS decides actual timing based on usage patterns | You cannot guarantee sync frequency; design for eventual consistency |
| iOS | Background tasks get ~30 seconds of execution time | Keep sync operations small; use incremental sync |
| Android | WorkManager respects battery optimization (Doze mode) | May be delayed hours on some OEM Android skins |
| Android | Headless JS requires the service to be registered in AndroidManifest.xml | Extra native configuration step |
| Expo | expo-background-fetch wraps the same APIs with managed config | Simpler setup but same OS-level constraints |
Expo Background Fetch
// core/sync/expoBackgroundSync.ts
import * as BackgroundFetch from 'expo-background-fetch';
import * as TaskManager from 'expo-task-manager';
import { syncEngine } from './SyncEngine';
const SYNC_TASK = 'background-sync';
TaskManager.defineTask(SYNC_TASK, async () => {
try {
await syncEngine.fullSync();
return BackgroundFetch.BackgroundFetchResult.NewData;
} catch {
return BackgroundFetch.BackgroundFetchResult.Failed;
}
});
export async function registerBackgroundSync() {
const status = await BackgroundFetch.getStatusAsync();
if (status === BackgroundFetch.BackgroundFetchStatus.Denied) {
console.warn('Background fetch is disabled in system settings');
return;
}
await BackgroundFetch.registerTaskAsync(SYNC_TASK, {
minimumInterval: 15 * 60, // seconds
stopOnTerminate: false,
startOnBoot: true,
});
}Error Handling and Retry
Exponential Backoff
// core/sync/retry.ts
export function calculateBackoff(retryCount: number, baseMs = 1000, maxMs = 60_000): number {
const delay = Math.min(baseMs * Math.pow(2, retryCount), maxMs);
// Add jitter to prevent thundering herd
const jitter = delay * 0.2 * Math.random();
return delay + jitter;
}
export async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 5,
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxRetries) break;
if (isNonRetryable(error)) break;
const delay = calculateBackoff(attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}
function isNonRetryable(error: unknown): boolean {
if (error instanceof HttpError) {
// 4xx errors (except 429) are client errors — retrying won't help
return error.status >= 400 && error.status < 500 && error.status !== 429;
}
return false;
}Dead Letter Queue
Operations that exhaust all retries should not be silently dropped. Move them to a dead letter queue for inspection and manual retry.
// core/sync/deadLetterQueue.ts
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV({ id: 'sync-engine' });
interface DeadLetter {
operation: SyncOperation;
failedAt: number;
lastError: string;
}
export function getDeadLetters(): DeadLetter[] {
const raw = storage.getString('dead-letter-queue');
return raw ? JSON.parse(raw) : [];
}
export function retryDeadLetter(id: string) {
const dlq = getDeadLetters();
const entry = dlq.find((d) => d.operation.id === id);
if (!entry) return;
// Move back to main queue with reset retry count
const queue = JSON.parse(storage.getString('queue') ?? '[]');
queue.push({ ...entry.operation, retryCount: 0 });
storage.set('queue', JSON.stringify(queue));
// Remove from DLQ
storage.set(
'dead-letter-queue',
JSON.stringify(dlq.filter((d) => d.operation.id !== id)),
);
}
export function clearDeadLetterQueue() {
storage.set('dead-letter-queue', '[]');
}User Notification for Sync Failures
// features/sync/components/SyncErrorBanner.tsx
import { useSyncStatus } from '../../../core/sync/useSyncStatus';
import { getDeadLetters, retryDeadLetter } from '../../../core/sync/deadLetterQueue';
export function SyncErrorBanner() {
const { status, error } = useSyncStatus();
const deadLetters = getDeadLetters();
if (status !== 'error' && deadLetters.length === 0) return null;
return (
<View style={styles.banner}>
<Text style={styles.title}>
{deadLetters.length} change{deadLetters.length !== 1 ? 's' : ''} could not be synced
</Text>
<Text style={styles.subtitle}>
{error ?? 'These changes are saved locally and will be retried.'}
</Text>
<Pressable
onPress={() => deadLetters.forEach((d) => retryDeadLetter(d.operation.id))}
style={styles.retryButton}
>
<Text style={styles.retryText}>Retry All</Text>
</Pressable>
</View>
);
}TanStack Query Mutation Cache Persistence
Mutations made offline can be persisted and resumed when connectivity returns.
// core/api/mutationPersistence.ts
import { MutationCache, QueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient({
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
// Global error handler for all mutations
if (mutation.meta?.showErrorToast) {
showToast(`Sync failed: ${error.message}`);
}
},
}),
defaultOptions: {
mutations: {
// Retry mutations that failed due to network
retry: (failureCount, error) => {
if (error instanceof TypeError && error.message === 'Network request failed') {
return failureCount < 5;
}
return false;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30_000),
},
},
});Testing Offline Scenarios
Mocking NetInfo
// __mocks__/@react-native-community/netinfo.ts
const listeners: Array<(state: any) => void> = [];
let currentState = { isConnected: true, isInternetReachable: true, type: 'wifi' };
const NetInfo = {
addEventListener: (callback: (state: any) => void) => {
listeners.push(callback);
callback(currentState);
return () => {
const idx = listeners.indexOf(callback);
if (idx >= 0) listeners.splice(idx, 1);
};
},
fetch: jest.fn(() => Promise.resolve(currentState)),
// Test helpers
__setConnected: (connected: boolean) => {
currentState = { ...currentState, isConnected: connected, isInternetReachable: connected };
listeners.forEach((l) => l(currentState));
},
};
export default NetInfo;Testing TanStack Query Offline Behavior
// features/tasks/__tests__/useToggleTask.test.tsx
import { renderHook, waitFor } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useToggleTask } from '../hooks/useToggleTask';
import NetInfo from '@react-native-community/netinfo';
function createWrapper() {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
}
test('optimistically updates task and rolls back on error', async () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useToggleTask(), { wrapper });
// Seed cache with initial data
const qc = result.current.queryClient;
qc.setQueryData(['tasks'], [{ id: '1', title: 'Test', isCompleted: false }]);
// Simulate offline
(NetInfo as any).__setConnected(false);
// Trigger mutation — will optimistically update
result.current.mutate({ id: '1', title: 'Test', isCompleted: false });
await waitFor(() => {
const tasks = qc.getQueryData<any[]>(['tasks']);
expect(tasks?.[0].isCompleted).toBe(true); // optimistic
});
});Testing Sync Logic
// core/sync/__tests__/SyncEngine.test.ts
import { SyncEngine } from '../SyncEngine';
import { MMKV } from 'react-native-mmkv';
jest.mock('react-native-mmkv');
jest.mock('@react-native-community/netinfo');
describe('SyncEngine', () => {
let engine: SyncEngine;
beforeEach(() => {
engine = new SyncEngine();
jest.clearAllMocks();
});
test('skips push when offline', async () => {
const NetInfo = require('@react-native-community/netinfo').default;
NetInfo.fetch.mockResolvedValue({ isConnected: false });
await engine.push();
expect(api.post).not.toHaveBeenCalled();
});
test('moves operations to DLQ after max retries', async () => {
const NetInfo = require('@react-native-community/netinfo').default;
NetInfo.fetch.mockResolvedValue({ isConnected: true });
// Seed queue with an operation at max retries
const storage = new MMKV({ id: 'sync-engine' });
storage.set('queue', JSON.stringify([
{ id: '1', table: 'tasks', type: 'create', payload: {}, createdAt: 0, retryCount: 5 },
]));
api.post.mockRejectedValue(new Error('Server error'));
await engine.push();
const dlq = JSON.parse(storage.getString('dead-letter-queue') ?? '[]');
expect(dlq).toHaveLength(1);
expect(dlq[0].id || dlq[0].operation?.id).toBeDefined();
});
});Anti-Patterns
Treating Cache as Source of Truth Without Versioning
A TanStack Query cache or MMKV snapshot has no schema version. If you ship an app update that changes the data shape, stale cached data will crash on hydration. Always version your persisted cache and clear it on incompatible changes.
// Clear stale cache on schema change
const CACHE_VERSION = 3;
const storedVersion = mmkv.getNumber('cache-version');
if (storedVersion !== CACHE_VERSION) {
mmkv.clearAll();
mmkv.set('cache-version', CACHE_VERSION);
}AsyncStorage for Structured Data
AsyncStorage has no query capability, no indexing, and serializes everything as JSON. Querying "all tasks where isCompleted = false" means deserializing the entire dataset and filtering in JS. Use a real database.
Unbounded Mutation Queues
If the user makes thousands of changes offline, a naive queue will grow without limit, consuming memory and making the next sync extremely slow. Bound the queue, coalesce operations on the same record, and alert the user when the queue is large.
// Coalesce: if the queue already has a pending update for this record, replace it
function enqueueCoalesced(op: SyncOperation) {
const queue = getQueue();
const existingIdx = queue.findIndex(
(q) => q.table === op.table && q.recordId === op.recordId && q.type === op.type,
);
if (existingIdx >= 0) {
queue[existingIdx] = { ...queue[existingIdx], payload: op.payload };
} else {
queue.push(op);
}
storage.set('queue', JSON.stringify(queue));
}Ignoring Partial Sync Failures
A batch push might succeed for 48 of 50 operations. If you treat the entire batch as failed, you retry the 48 that already succeeded -- at best wasting bandwidth, at worst creating duplicates if the server is not idempotent. Track success per operation, not per batch.
Syncing on Every State Change
Triggering a network request on every local write creates unnecessary traffic and drains battery. Debounce sync to a reasonable interval (e.g., every 30 seconds while online) and let background sync handle the rest.
// Debounced sync trigger
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
export function scheduleSyncDebounced(delayMs = 30_000) {
if (syncTimeout) clearTimeout(syncTimeout);
syncTimeout = setTimeout(() => {
syncEngine.push();
syncTimeout = null;
}, delayMs);
}