Steven's Knowledge

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.

ApproachBehavior offlinePerceived latencyComplexity
Online-onlySpinner, then errorNetwork RTT on every actionLow
Cache-then-networkStale data if cached, error if notFast on cache hitMedium
Offline-firstFull read/write capabilityNear-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

LibraryQuery CapabilityReactive UpdatesSync SupportPerformanceEncryptionBest For
WatermelonDBSQL (SQLite under the hood)Built-in observable queriesBuilt-in sync protocolLazy-loading, very fast for large datasetsVia SQLCipher forkApps with thousands of records and complex relations
RealmObject query languageLive objects auto-updateRealm Sync (Atlas Device Sync)Native C++ engine, fastBuilt-inTeams using MongoDB Atlas backend
expo-sqliteRaw SQLManual (re-query on change)ManualGood, uses platform SQLiteNoExpo projects needing direct SQL control
op-sqliteRaw SQLManualManualFastest SQLite binding (JSI)Via SQLCipherPerformance-critical bare RN projects
MMKVKey-value onlyManual listenersManualExtremely fast for small valuesBuilt-inPreferences, tokens, small serialized state
AsyncStorageKey-value onlyNoneNoneSlow (async bridge, JSON serialize)NoLegacy 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 metadata

WatermelonDB 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 updates

This 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

StrategyHow it worksTrade-off
Last-write-wins (LWW)Compare updatedAt timestamps; latest write survivesSimple but loses data silently
Server-winsServer state always overwrites clientSafe but frustrating for the user who made changes offline
Client-winsClient state always overwrites serverDangerous if multiple clients exist
Field-level mergeMerge non-conflicting fields; flag true conflicts for manual resolutionMost correct but most complex
CRDTsConflict-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

ApproachWhen to useTrade-off
Full syncInitial load, schema migration, data corruption recoveryTransfers everything; expensive on large datasets
Incremental syncEvery subsequent sync after initialOnly 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=1700001000000

Use 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

PlatformConstraintImplication
iOSMinimum 15-minute interval; OS decides actual timing based on usage patternsYou cannot guarantee sync frequency; design for eventual consistency
iOSBackground tasks get ~30 seconds of execution timeKeep sync operations small; use incremental sync
AndroidWorkManager respects battery optimization (Doze mode)May be delayed hours on some OEM Android skins
AndroidHeadless JS requires the service to be registered in AndroidManifest.xmlExtra native configuration step
Expoexpo-background-fetch wraps the same APIs with managed configSimpler 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);
}

On this page