Offline-First Architecture in React Native Apps
Offline-First Architecture in React Native Apps
Sync strategies, conflict resolution, local-first databases like WatermelonDB and PowerSync, handling optimistic updates across sessions, and designing UX that doesn't lie to users when connectivity drops.
Offline-Capable vs Offline-First
Most apps are offline-capable: they cache data and gracefully degrade when connectivity drops. Few apps are offline-first: they work fully without connectivity and treat the network as an optimization, not a requirement.
┌─────────────────────────────────────────────────────────────────────────────┐
│ THE ARCHITECTURAL DIFFERENCE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ OFFLINE-CAPABLE (most apps): │
│ ───────────────────────────── │
│ • Network is primary, cache is fallback │
│ • Writes require connectivity │
│ • Offline = read-only mode │
│ • Reconnection = "sync" (fetch latest from server) │
│ │
│ Data flow: │
│ User Action → API Call → Update Local Cache → Update UI │
│ ↓ │
│ (fails offline) │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ OFFLINE-FIRST (this article): │
│ ────────────────────────────── │
│ • Local database is primary, server is sync target │
│ • Writes always succeed locally │
│ • Full functionality offline │
│ • Reconnection = bidirectional sync with conflict resolution │
│ │
│ Data flow: │
│ User Action → Write to Local DB → Update UI → (async) Sync to Server │
│ │
│ The local database is the source of truth for the UI. │
│ The server is eventually consistent with local state. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Local Database Layer
Choosing a Database
┌─────────────────────────────────────────────────────────────────────────────┐
│ LOCAL DATABASE OPTIONS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ DATABASE SYNC BUILT-IN QUERY POWER REACT NATIVE NOTES │
│ ───────────────────────────────────────────────────────────────────────── │
│ WatermelonDB No (DIY) SQL-like Excellent Fast, lazy │
│ PowerSync Yes (Postgres) SQL Good Managed sync │
│ Realm Yes (MongoDB) Object query Good Sync to Atlas│
│ SQLite (raw) No Full SQL Manual Most flexible│
│ MMKV No Key-value Excellent Simple data │
│ RxDB Optional NoSQL Fair CouchDB sync │
│ │
│ RECOMMENDATIONS: │
│ ───────────────── │
│ Complex relational data + custom backend → WatermelonDB │
│ Postgres backend + managed sync → PowerSync │
│ MongoDB backend + managed sync → Realm │
│ Simple key-value + speed → MMKV │
│ Maximum control + SQL expertise → SQLite │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
WatermelonDB Architecture
WatermelonDB is optimized for React Native with lazy loading and observable queries:
// 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' },
// Sync metadata
{ name: 'server_id', type: 'string', isOptional: true, isIndexed: true },
{ name: 'sync_status', type: 'string' }, // 'synced' | 'created' | 'updated' | 'deleted'
{ name: 'last_synced_at', type: 'number', isOptional: true },
],
}),
tableSchema({
name: 'projects',
columns: [
{ name: 'name', type: 'string' },
{ name: 'color', type: 'string' },
{ name: 'server_id', type: 'string', isOptional: true, isIndexed: true },
{ name: 'sync_status', type: 'string' },
{ name: 'last_synced_at', type: 'number', isOptional: true },
],
}),
],
});
// models/Task.ts
import { Model, Relation } from '@nozbe/watermelondb';
import { field, relation, date, readonly, writer } from '@nozbe/watermelondb/decorators';
export class Task extends Model {
static table = 'tasks';
static 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;
@date('created_at') createdAt!: Date;
@date('updated_at') updatedAt!: Date;
@field('server_id') serverId?: string;
@field('sync_status') syncStatus!: 'synced' | 'created' | 'updated' | 'deleted';
@date('last_synced_at') lastSyncedAt?: Date;
@relation('projects', 'project_id') project!: Relation<Project>;
@writer async markCompleted() {
await this.update(task => {
task.isCompleted = true;
task.syncStatus = this.syncStatus === 'created' ? 'created' : 'updated';
});
}
@writer async softDelete() {
await this.update(task => {
task.syncStatus = task.serverId ? 'deleted' : 'created';
// If never synced, we can just delete it
if (!task.serverId) {
task.markAsDeleted();
}
});
}
}
Observable Queries for React
// hooks/useTasks.ts
import { useDatabase } from '@nozbe/watermelondb/hooks';
import { Q } from '@nozbe/watermelondb';
import { useObservableState } from 'observable-hooks';
export function useTasks(projectId: string) {
const database = useDatabase();
const tasks$ = useMemo(() => {
return database
.get<Task>('tasks')
.query(
Q.where('project_id', projectId),
Q.where('sync_status', Q.notEq('deleted')),
Q.sortBy('created_at', Q.desc)
)
.observe();
}, [database, projectId]);
const tasks = useObservableState(tasks$, []);
return tasks;
}
// Component usage
function TaskList({ projectId }: { projectId: string }) {
const tasks = useTasks(projectId);
// Tasks automatically update when:
// - New tasks are created locally
// - Tasks are synced from server
// - Tasks are modified
// No manual refetching needed
return (
<FlatList
data={tasks}
renderItem={({ item }) => <TaskItem task={item} />}
keyExtractor={item => item.id}
/>
);
}
Sync Architecture
The Sync Protocol
┌─────────────────────────────────────────────────────────────────────────────┐
│ BIDIRECTIONAL SYNC FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ CLIENT SERVER │
│ │ │ │
│ 1. PUSH LOCAL CHANGES │ │ │
│ ───────────────── │ │ │
│ Collect records │ POST /sync │ │
│ with sync_status │ { │ │
│ != 'synced' │ lastSyncedAt: 1705123456, │ │
│ │ changes: { │ │
│ │ tasks: { │ │
│ │ created: [...], │ │
│ │ updated: [...], │ │
│ │ deleted: [...] │ │
│ │ } │ │
│ │ } │ │
│ │ } │ │
│ │ ──────────────────────────────▶│ │
│ │ │ │
│ 2. SERVER PROCESSES │ │ Apply changes │
│ ───────────────── │ │ Detect conflicts │
│ │ │ Resolve conflicts │
│ │ │ Return remote │
│ │ │ changes │
│ │ │ │
│ 3. PULL REMOTE CHANGES│ │ │
│ ───────────────── │ { │ │
│ │ serverTime: 1705123500, │ │
│ │ changes: { │ │
│ │ tasks: { │ │
│ │ created: [...], │ │
│ │ updated: [...], │ │
│ │ deleted: [...] │ │
│ │ } │ │
│ │ }, │ │
│ │ conflicts: [...] │ │
│ │ } │ │
│ │ ◀──────────────────────────────│ │
│ │ │ │
│ 4. APPLY + RESOLVE │ │ │
│ ─────────────── │ │ │
│ Apply remote │ │ │
│ Handle conflicts │ │ │
│ Mark as synced │ │ │
│ Update lastSyncedAt│ │ │
│ │ │ │
└─────────────────────────────────────────────────────────────────────────────┘
Sync Implementation
// sync/SyncEngine.ts
import { Database, Q } from '@nozbe/watermelondb';
import { synchronize } from '@nozbe/watermelondb/sync';
interface SyncPullResult {
changes: DatabaseChanges;
timestamp: number;
}
interface SyncPushResult {
conflicts: Conflict[];
timestamp: number;
}
interface Conflict {
table: string;
localRecord: Record<string, unknown>;
serverRecord: Record<string, unknown>;
resolution: 'server_wins' | 'client_wins' | 'merge' | 'manual';
}
class SyncEngine {
private database: Database;
private api: ApiClient;
private lastSyncedAt: number = 0;
private isSyncing: boolean = false;
private syncQueue: Promise<void> = Promise.resolve();
constructor(database: Database, api: ApiClient) {
this.database = database;
this.api = api;
}
async sync(): Promise<SyncResult> {
// Prevent concurrent syncs
if (this.isSyncing) {
return this.syncQueue.then(() => ({ status: 'skipped' }));
}
this.isSyncing = true;
try {
return await synchronize({
database: this.database,
pullChanges: this.pullChanges.bind(this),
pushChanges: this.pushChanges.bind(this),
migrationsEnabledAtVersion: 1,
});
} finally {
this.isSyncing = false;
}
}
private async pullChanges({ lastPulledAt, schemaVersion, migration }) {
const response = await this.api.post('/sync/pull', {
lastPulledAt,
schemaVersion,
migration,
});
return {
changes: response.changes,
timestamp: response.timestamp,
};
}
private async pushChanges({ changes, lastPulledAt }) {
const response = await this.api.post('/sync/push', {
changes,
lastPulledAt,
});
// Handle conflicts returned by server
if (response.conflicts?.length > 0) {
await this.resolveConflicts(response.conflicts);
}
}
private async resolveConflicts(conflicts: Conflict[]): Promise<void> {
for (const conflict of conflicts) {
const resolution = await this.resolveConflict(conflict);
await this.database.write(async () => {
const table = this.database.get(conflict.table);
const record = await table.find(conflict.localRecord.id);
switch (resolution.strategy) {
case 'server_wins':
await record.update(r => {
Object.assign(r, conflict.serverRecord);
r.syncStatus = 'synced';
});
break;
case 'client_wins':
// Server already has our version, just mark synced
await record.update(r => {
r.syncStatus = 'synced';
});
break;
case 'merge':
await record.update(r => {
Object.assign(r, resolution.mergedData);
r.syncStatus = 'synced';
});
break;
}
});
}
}
private async resolveConflict(conflict: Conflict): Promise<ConflictResolution> {
// Strategy depends on the data type and business rules
const local = conflict.localRecord;
const server = conflict.serverRecord;
// Example: Last-Write-Wins based on updated_at
if (local.updated_at > server.updated_at) {
return { strategy: 'client_wins' };
} else {
return { strategy: 'server_wins' };
}
}
}
Conflict Resolution Strategies
Strategy 1: Last-Write-Wins (LWW)
┌─────────────────────────────────────────────────────────────────────────────┐
│ LAST-WRITE-WINS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Timeline: │
│ ───────── │
│ T=0 Record: { title: "Original", updated_at: 100 } │
│ │
│ T=1 Device A (offline): Updates title to "Edit A", updated_at: 150 │
│ T=2 Device B (online): Updates title to "Edit B", updated_at: 200 │
│ T=3 Device A comes online, syncs │
│ │
│ Conflict detected: │
│ Local (A): { title: "Edit A", updated_at: 150 } │
│ Server: { title: "Edit B", updated_at: 200 } │
│ │
│ Resolution: Server wins (200 > 150) │
│ Final state: { title: "Edit B", updated_at: 200 } │
│ │
│ PROS: CONS: │
│ • Simple to implement • Can lose data silently │
│ • Deterministic • Clock sync issues │
│ • No user intervention • "Last" depends on unreliable clocks │
│ │
│ WHEN TO USE: │
│ • Low-stakes data │
│ • Frequent small updates │
│ • User expects "latest wins" │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
function resolveWithLWW(local: Record, server: Record): ConflictResolution {
// Use server timestamp as tie-breaker if equal
if (local.updated_at > server.updated_at) {
return { strategy: 'client_wins', record: local };
}
return { strategy: 'server_wins', record: server };
}
Strategy 2: Field-Level Merge
┌─────────────────────────────────────────────────────────────────────────────┐
│ FIELD-LEVEL MERGE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Original: { title: "Task", body: "Details", status: "pending" } │
│ │
│ Device A edits: { title: "Task A", body: "Details", status: "pending" } │
│ Changed: title │
│ │
│ Device B edits: { title: "Task", body: "New details", status: "done" } │
│ Changed: body, status │
│ │
│ No conflict! Different fields changed. │
│ Merged result: { title: "Task A", body: "New details", status: "done" } │
│ │
│ IMPLEMENTATION: │
│ ──────────────── │
│ Track which fields changed locally since last sync. │
│ Only those fields are "owned" by local. │
│ Non-conflicting server changes are applied. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
interface RecordWithDirtyFields {
id: string;
data: Record<string, unknown>;
dirtyFields: Set<string>;
baseVersion: number;
}
function fieldLevelMerge(
base: Record<string, unknown>,
local: RecordWithDirtyFields,
server: Record<string, unknown>
): { merged: Record<string, unknown>; hasConflict: boolean } {
const merged = { ...server };
let hasConflict = false;
for (const field of local.dirtyFields) {
const localValue = local.data[field];
const serverValue = server[field];
const baseValue = base[field];
if (serverValue === baseValue) {
// Server didn't change this field, apply local change
merged[field] = localValue;
} else if (localValue === serverValue) {
// Both made same change, no conflict
merged[field] = localValue;
} else {
// True conflict: both changed to different values
hasConflict = true;
// Default to local (or could prompt user)
merged[field] = localValue;
}
}
return { merged, hasConflict };
}
Strategy 3: Operational Transformation (OT)
For text editing and collaborative content:
// Simplified OT for text
interface TextOperation {
type: 'insert' | 'delete';
position: number;
text?: string; // For insert
length?: number; // For delete
timestamp: number;
clientId: string;
}
function transformOperation(
op: TextOperation,
againstOp: TextOperation
): TextOperation {
// Transform op assuming againstOp was applied first
if (againstOp.type === 'insert') {
if (op.position >= againstOp.position) {
// Shift position right by inserted text length
return {
...op,
position: op.position + (againstOp.text?.length ?? 0),
};
}
}
if (againstOp.type === 'delete') {
if (op.position >= againstOp.position + (againstOp.length ?? 0)) {
// Shift position left by deleted text length
return {
...op,
position: op.position - (againstOp.length ?? 0),
};
}
if (op.position >= againstOp.position) {
// Operation is within deleted range
return {
...op,
position: againstOp.position,
};
}
}
return op;
}
// Apply operation log to resolve text conflicts
function resolveTextConflict(
baseText: string,
localOps: TextOperation[],
serverOps: TextOperation[]
): string {
// Transform local ops against server ops
const transformedLocalOps = localOps.map(localOp => {
let transformed = localOp;
for (const serverOp of serverOps) {
transformed = transformOperation(transformed, serverOp);
}
return transformed;
});
// Apply server ops first, then transformed local ops
let result = baseText;
for (const op of serverOps) {
result = applyOperation(result, op);
}
for (const op of transformedLocalOps) {
result = applyOperation(result, op);
}
return result;
}
Strategy 4: CRDTs (Conflict-free Replicated Data Types)
// LWW-Register CRDT
interface LWWRegister<T> {
value: T;
timestamp: number;
nodeId: string;
}
function mergeLWWRegister<T>(
a: LWWRegister<T>,
b: LWWRegister<T>
): LWWRegister<T> {
if (a.timestamp > b.timestamp) return a;
if (b.timestamp > a.timestamp) return b;
// Tie-breaker: compare nodeId lexicographically
return a.nodeId > b.nodeId ? a : b;
}
// G-Counter CRDT (grow-only counter)
interface GCounter {
counts: Record<string, number>; // nodeId -> count
}
function incrementGCounter(counter: GCounter, nodeId: string): GCounter {
return {
counts: {
...counter.counts,
[nodeId]: (counter.counts[nodeId] ?? 0) + 1,
},
};
}
function mergeGCounters(a: GCounter, b: GCounter): GCounter {
const allNodes = new Set([...Object.keys(a.counts), ...Object.keys(b.counts)]);
const merged: Record<string, number> = {};
for (const nodeId of allNodes) {
merged[nodeId] = Math.max(a.counts[nodeId] ?? 0, b.counts[nodeId] ?? 0);
}
return { counts: merged };
}
function valueOfGCounter(counter: GCounter): number {
return Object.values(counter.counts).reduce((sum, n) => sum + n, 0);
}
// OR-Set CRDT (observed-remove set)
interface ORSet<T> {
elements: Map<T, Set<string>>; // element -> set of unique add-ids
tombstones: Set<string>; // removed add-ids
}
function addToORSet<T>(set: ORSet<T>, element: T, addId: string): ORSet<T> {
const newElements = new Map(set.elements);
const addIds = newElements.get(element) ?? new Set();
addIds.add(addId);
newElements.set(element, addIds);
return { elements: newElements, tombstones: set.tombstones };
}
function removeFromORSet<T>(set: ORSet<T>, element: T): ORSet<T> {
const addIds = set.elements.get(element);
if (!addIds) return set;
const newTombstones = new Set(set.tombstones);
for (const addId of addIds) {
newTombstones.add(addId);
}
const newElements = new Map(set.elements);
newElements.delete(element);
return { elements: newElements, tombstones: newTombstones };
}
function mergeORSets<T>(a: ORSet<T>, b: ORSet<T>): ORSet<T> {
const mergedTombstones = new Set([...a.tombstones, ...b.tombstones]);
const mergedElements = new Map<T, Set<string>>();
// Merge all elements from both sets
const allElements = new Set([...a.elements.keys(), ...b.elements.keys()]);
for (const element of allElements) {
const aIds = a.elements.get(element) ?? new Set();
const bIds = b.elements.get(element) ?? new Set();
const mergedIds = new Set([...aIds, ...bIds]);
// Remove tombstoned add-ids
for (const id of mergedIds) {
if (mergedTombstones.has(id)) {
mergedIds.delete(id);
}
}
if (mergedIds.size > 0) {
mergedElements.set(element, mergedIds);
}
}
return { elements: mergedElements, tombstones: mergedTombstones };
}
Optimistic Updates Across Sessions
The challenge: user makes a change, app crashes, they reopen — the change should still be there and eventually sync.
Operation Queue
// sync/OperationQueue.ts
interface PendingOperation {
id: string;
type: 'create' | 'update' | 'delete';
table: string;
recordId: string;
payload: Record<string, unknown>;
createdAt: number;
retryCount: number;
lastError?: string;
}
class OperationQueue {
private db: Database;
private processing: boolean = false;
constructor(db: Database) {
this.db = db;
}
async enqueue(operation: Omit<PendingOperation, 'id' | 'createdAt' | 'retryCount'>): Promise<void> {
await this.db.write(async () => {
const queue = this.db.get<PendingOperationRecord>('pending_operations');
await queue.create(record => {
record.type = operation.type;
record.table = operation.table;
record.recordId = operation.recordId;
record.payload = JSON.stringify(operation.payload);
record.createdAt = Date.now();
record.retryCount = 0;
});
});
this.processQueue();
}
async processQueue(): Promise<void> {
if (this.processing) return;
this.processing = true;
try {
while (true) {
const nextOp = await this.getNextOperation();
if (!nextOp) break;
try {
await this.processOperation(nextOp);
await this.markCompleted(nextOp.id);
} catch (error) {
await this.handleError(nextOp, error);
}
}
} finally {
this.processing = false;
}
}
private async processOperation(op: PendingOperation): Promise<void> {
const api = getApiClient();
switch (op.type) {
case 'create':
const created = await api.post(`/${op.table}`, op.payload);
// Update local record with server ID
await this.updateLocalWithServerId(op.table, op.recordId, created.id);
break;
case 'update':
await api.patch(`/${op.table}/${op.payload.serverId}`, op.payload);
break;
case 'delete':
await api.delete(`/${op.table}/${op.payload.serverId}`);
// Actually remove from local DB
await this.removeLocalRecord(op.table, op.recordId);
break;
}
}
private async handleError(op: PendingOperation, error: Error): Promise<void> {
const maxRetries = 3;
if (op.retryCount >= maxRetries) {
// Move to dead letter queue
await this.moveToDeadLetter(op, error);
return;
}
if (this.isRetryable(error)) {
await this.incrementRetryCount(op.id, error.message);
// Exponential backoff
const delay = Math.pow(2, op.retryCount) * 1000;
setTimeout(() => this.processQueue(), delay);
} else {
// Non-retryable error (e.g., 404, 400)
await this.moveToDeadLetter(op, error);
}
}
private isRetryable(error: Error): boolean {
if (error instanceof NetworkError) return true;
if (error instanceof ApiError) {
return error.status >= 500 || error.status === 429;
}
return false;
}
}
Persisting Optimistic State
// hooks/useOptimisticMutation.ts
import { useDatabase } from '@nozbe/watermelondb/hooks';
import NetInfo from '@react-native-community/netinfo';
interface OptimisticMutationOptions<T> {
// Apply change locally immediately
optimisticUpdate: (db: Database) => Promise<T>;
// Server mutation (runs when online)
serverMutation: (localResult: T, api: ApiClient) => Promise<void>;
// Rollback on failure
rollback?: (db: Database, localResult: T, error: Error) => Promise<void>;
}
function useOptimisticMutation<T>(options: OptimisticMutationOptions<T>) {
const database = useDatabase();
const operationQueue = useOperationQueue();
return async () => {
// 1. Apply optimistic update to local DB
const localResult = await options.optimisticUpdate(database);
// 2. Check connectivity
const netState = await NetInfo.fetch();
if (netState.isConnected) {
// 3a. Online: try to sync immediately
try {
await options.serverMutation(localResult, api);
// Mark as synced
await markAsSynced(database, localResult);
} catch (error) {
if (isNetworkError(error)) {
// Lost connection during request, queue it
await operationQueue.enqueue({
type: 'create',
table: getTable(localResult),
recordId: localResult.id,
payload: serializeForApi(localResult),
});
} else {
// Server rejected the change
await options.rollback?.(database, localResult, error);
throw error;
}
}
} else {
// 3b. Offline: queue for later
await operationQueue.enqueue({
type: 'create',
table: getTable(localResult),
recordId: localResult.id,
payload: serializeForApi(localResult),
});
}
return localResult;
};
}
// Usage
function TaskScreen() {
const createTask = useOptimisticMutation({
optimisticUpdate: async (db) => {
return await db.write(async () => {
const task = await db.get('tasks').create(record => {
record.title = 'New Task';
record.syncStatus = 'created';
});
return task;
});
},
serverMutation: async (task, api) => {
const serverTask = await api.post('/tasks', {
title: task.title,
});
// Store server ID
await task.update(t => {
t.serverId = serverTask.id;
t.syncStatus = 'synced';
});
},
rollback: async (db, task, error) => {
// Remove the optimistically created task
await task.markAsDeleted();
showErrorToast('Failed to create task');
},
});
return (
<Button onPress={() => createTask()}>
Create Task
</Button>
);
}
UX Patterns for Offline-First
Don't Lie to Users
┌─────────────────────────────────────────────────────────────────────────────┐
│ UX TRUTHFULNESS SPECTRUM │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ LYING: │
│ "Message sent!" (it's only queued) │
│ │
│ ⚠️ CONFUSING: │
│ "Message saved" (saved where? for how long?) │
│ │
│ ✅ TRUTHFUL: │
│ "Message will send when you're back online" │
│ │
│ ✅ EVEN BETTER: │
│ [Message bubble with subtle "sending..." indicator] │
│ [Indicator changes to ✓ when actually delivered] │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ DESIGN PRINCIPLES: │
│ ───────────────── │
│ 1. Show sync status without being annoying │
│ 2. Never show "error" for expected offline behavior │
│ 3. Let users know what's pending │
│ 4. Make sync state visible but not intrusive │
│ 5. Celebrate successful sync (briefly) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Sync Status Indicator
// components/SyncStatusIndicator.tsx
import { useNetInfo } from '@react-native-community/netinfo';
import { usePendingOperations } from '../hooks/usePendingOperations';
type SyncState = 'synced' | 'syncing' | 'pending' | 'offline' | 'error';
function SyncStatusIndicator() {
const netInfo = useNetInfo();
const { pendingCount, hasErrors, isSyncing } = usePendingOperations();
const state: SyncState = useMemo(() => {
if (!netInfo.isConnected) return 'offline';
if (hasErrors) return 'error';
if (isSyncing) return 'syncing';
if (pendingCount > 0) return 'pending';
return 'synced';
}, [netInfo.isConnected, hasErrors, isSyncing, pendingCount]);
return (
<View style={styles.container}>
<StatusIcon state={state} />
<StatusText state={state} pendingCount={pendingCount} />
</View>
);
}
function StatusIcon({ state }: { state: SyncState }) {
switch (state) {
case 'synced':
return <CheckIcon color="green" />;
case 'syncing':
return <ActivityIndicator size="small" />;
case 'pending':
return <CloudQueueIcon color="orange" />;
case 'offline':
return <WifiOffIcon color="gray" />;
case 'error':
return <AlertIcon color="red" />;
}
}
function StatusText({ state, pendingCount }: { state: SyncState; pendingCount: number }) {
switch (state) {
case 'synced':
return null; // Don't show text when everything's fine
case 'syncing':
return <Text style={styles.statusText}>Syncing...</Text>;
case 'pending':
return (
<Text style={styles.statusText}>
{pendingCount} change{pendingCount > 1 ? 's' : ''} pending
</Text>
);
case 'offline':
return <Text style={styles.statusText}>Offline - changes will sync later</Text>;
case 'error':
return (
<Pressable onPress={showErrorDetails}>
<Text style={styles.errorText}>Sync issue - tap for details</Text>
</Pressable>
);
}
}
Item-Level Sync Status
// components/TaskItem.tsx
interface TaskItemProps {
task: Task;
}
function TaskItem({ task }: TaskItemProps) {
const syncStatus = task.syncStatus;
return (
<View style={styles.container}>
<View style={styles.content}>
<Text style={[
styles.title,
syncStatus === 'deleted' && styles.deletedTitle,
]}>
{task.title}
</Text>
</View>
{/* Subtle sync indicator */}
<SyncBadge status={syncStatus} />
</View>
);
}
function SyncBadge({ status }: { status: string }) {
switch (status) {
case 'synced':
return null; // No badge needed
case 'created':
return (
<View style={styles.badge}>
<CloudUploadIcon size={12} color="#888" />
</View>
);
case 'updated':
return (
<View style={styles.badge}>
<SyncIcon size={12} color="#888" />
</View>
);
case 'deleted':
return (
<View style={[styles.badge, styles.deletedBadge]}>
<TrashIcon size={12} color="#888" />
<Text style={styles.badgeText}>Deleting...</Text>
</View>
);
}
}
Connectivity State Banner
// components/OfflineBanner.tsx
import Animated, {
useAnimatedStyle,
withTiming,
interpolate,
} from 'react-native-reanimated';
function OfflineBanner() {
const netInfo = useNetInfo();
const [wasOffline, setWasOffline] = useState(false);
const [showingReconnected, setShowingReconnected] = useState(false);
useEffect(() => {
if (!netInfo.isConnected) {
setWasOffline(true);
} else if (wasOffline) {
setShowingReconnected(true);
// Hide "Back online" message after 3 seconds
const timer = setTimeout(() => {
setShowingReconnected(false);
setWasOffline(false);
}, 3000);
return () => clearTimeout(timer);
}
}, [netInfo.isConnected, wasOffline]);
const isVisible = !netInfo.isConnected || showingReconnected;
const animatedStyle = useAnimatedStyle(() => ({
height: withTiming(isVisible ? 44 : 0),
opacity: withTiming(isVisible ? 1 : 0),
}));
if (!isVisible && !netInfo.isConnected) return null;
return (
<Animated.View
style={[
styles.banner,
!netInfo.isConnected ? styles.offlineBanner : styles.onlineBanner,
animatedStyle,
]}
>
{!netInfo.isConnected ? (
<>
<WifiOffIcon color="white" size={18} />
<Text style={styles.bannerText}>
You're offline. Changes will sync when you reconnect.
</Text>
</>
) : (
<>
<CheckIcon color="white" size={18} />
<Text style={styles.bannerText}>Back online!</Text>
</>
)}
</Animated.View>
);
}
PowerSync Integration
PowerSync provides managed sync for Postgres backends:
// db/PowerSyncSetup.ts
import { PowerSyncDatabase } from '@powersync/react-native';
import { PostgresConnector } from '@powersync/react-native';
const schema = new Schema([
new Table({
name: 'tasks',
columns: [
new Column({ name: 'title', type: ColumnType.TEXT }),
new Column({ name: 'completed', type: ColumnType.INTEGER }),
new Column({ name: 'created_at', type: ColumnType.TEXT }),
new Column({ name: 'owner_id', type: ColumnType.TEXT }),
],
indexes: [
new Index({ name: 'owner', columns: ['owner_id'] }),
],
}),
]);
class Connector implements PowerSyncBackendConnector {
async fetchCredentials(): Promise<PowerSyncCredentials> {
const response = await fetch('/api/powersync-token');
const { token, endpoint } = await response.json();
return { token, endpoint };
}
async uploadData(database: PowerSyncDatabase): Promise<void> {
const transaction = await database.getNextCrudTransaction();
if (!transaction) return;
try {
for (const op of transaction.crud) {
switch (op.op) {
case 'PUT':
await api.upsert(op.table, op.data);
break;
case 'PATCH':
await api.update(op.table, op.id, op.data);
break;
case 'DELETE':
await api.delete(op.table, op.id);
break;
}
}
await transaction.complete();
} catch (error) {
// PowerSync handles retry logic
throw error;
}
}
}
export const powerSync = new PowerSyncDatabase({
schema,
database: { dbFilename: 'app.db' },
});
export async function setupPowerSync() {
const connector = new Connector();
await powerSync.init();
await powerSync.connect(connector);
}
// hooks/useTasks.ts (with PowerSync)
import { useQuery } from '@powersync/react-native';
export function useTasks(ownerId: string) {
const { data: tasks, isLoading, error } = useQuery<Task>(`
SELECT * FROM tasks
WHERE owner_id = ?
ORDER BY created_at DESC
`, [ownerId]);
return { tasks, isLoading, error };
}
// Mutations work the same way — write locally, sync happens automatically
export function useCreateTask() {
const powerSync = usePowerSync();
return async (task: Omit<Task, 'id'>) => {
const id = uuid();
await powerSync.execute(`
INSERT INTO tasks (id, title, completed, created_at, owner_id)
VALUES (?, ?, ?, ?, ?)
`, [id, task.title, 0, new Date().toISOString(), task.ownerId]);
return { id, ...task };
};
}
Testing Offline Scenarios
// __tests__/sync.test.ts
import { Database } from '@nozbe/watermelondb';
import { SyncEngine } from '../sync/SyncEngine';
describe('Offline Sync', () => {
let database: Database;
let syncEngine: SyncEngine;
let mockApi: jest.Mocked<ApiClient>;
beforeEach(async () => {
database = await createTestDatabase();
mockApi = createMockApi();
syncEngine = new SyncEngine(database, mockApi);
});
it('queues changes made offline', async () => {
// Simulate offline
mockApi.post.mockRejectedValue(new NetworkError());
// Create task while offline
await database.write(async () => {
await database.get('tasks').create(task => {
task.title = 'Offline Task';
task.syncStatus = 'created';
});
});
// Verify task is in local DB with correct status
const tasks = await database.get('tasks').query().fetch();
expect(tasks).toHaveLength(1);
expect(tasks[0].syncStatus).toBe('created');
});
it('syncs queued changes when back online', async () => {
// Create task offline
const task = await database.write(async () => {
return database.get('tasks').create(t => {
t.title = 'Offline Task';
t.syncStatus = 'created';
});
});
// Simulate coming back online
mockApi.post.mockResolvedValue({ id: 'server-123' });
mockApi.fetch.mockResolvedValue({ changes: {}, timestamp: Date.now() });
// Trigger sync
await syncEngine.sync();
// Verify task is synced
const synced = await database.get('tasks').find(task.id);
expect(synced.syncStatus).toBe('synced');
expect(synced.serverId).toBe('server-123');
});
it('resolves conflicts with server-wins strategy', async () => {
// Setup: task exists on both client and server
const task = await createSyncedTask(database, {
title: 'Original',
serverId: 'server-123',
});
// Client modifies while offline
await task.update(t => {
t.title = 'Client Edit';
t.syncStatus = 'updated';
});
// Server has different modification
mockApi.post.mockResolvedValue({
conflicts: [{
table: 'tasks',
localRecord: { id: task.id, title: 'Client Edit' },
serverRecord: { id: 'server-123', title: 'Server Edit', updated_at: Date.now() + 1000 },
}],
});
await syncEngine.sync();
// With LWW, server wins (newer timestamp)
const resolved = await database.get('tasks').find(task.id);
expect(resolved.title).toBe('Server Edit');
expect(resolved.syncStatus).toBe('synced');
});
it('handles sync interruption gracefully', async () => {
// Create multiple tasks offline
for (let i = 0; i < 5; i++) {
await database.write(async () => {
await database.get('tasks').create(t => {
t.title = `Task ${i}`;
t.syncStatus = 'created';
});
});
}
// First 2 succeed, then network fails
let callCount = 0;
mockApi.post.mockImplementation(async () => {
callCount++;
if (callCount <= 2) return { id: `server-${callCount}` };
throw new NetworkError();
});
// Attempt sync
try {
await syncEngine.sync();
} catch (e) {
// Expected to fail
}
// Verify: 2 synced, 3 still pending
const tasks = await database.get('tasks').query().fetch();
const synced = tasks.filter(t => t.syncStatus === 'synced');
const pending = tasks.filter(t => t.syncStatus === 'created');
expect(synced).toHaveLength(2);
expect(pending).toHaveLength(3);
});
});
Production Checklist
┌─────────────────────────────────────────────────────────────────────────────┐
│ OFFLINE-FIRST PRODUCTION CHECKLIST │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ DATA LAYER: │
│ ─────────── │
│ □ Local database schema includes sync metadata (server_id, sync_status) │
│ □ Migrations handle schema changes gracefully │
│ □ Indexes on frequently queried fields │
│ □ Database encryption for sensitive data (SQLCipher) │
│ │
│ SYNC ENGINE: │
│ ──────────── │
│ □ Conflict resolution strategy documented and tested │
│ □ Retry logic with exponential backoff │
│ □ Dead letter queue for failed operations │
│ □ Sync progress persistence (survives app restart) │
│ □ Bandwidth-aware sync (don't sync large files on cellular) │
│ │
│ NETWORKING: │
│ ─────────── │
│ □ NetInfo listener for connectivity changes │
│ □ Background sync when app is backgrounded (BackgroundFetch) │
│ □ Push notification trigger for urgent syncs │
│ □ Sync debouncing to prevent excessive requests │
│ │
│ UX: │
│ ──── │
│ □ Sync status indicator (subtle but visible) │
│ □ Per-item sync status for pending changes │
│ □ Offline banner (non-intrusive) │
│ □ Reconnection celebration (brief) │
│ □ Conflict resolution UI for manual conflicts │
│ □ Pending changes count in settings │
│ │
│ ERROR HANDLING: │
│ ─────────────── │
│ □ Distinguish network errors from server errors │
│ □ Surface actionable errors to user │
│ □ Logging for sync failures (analytics) │
│ □ Crash reporting includes sync state │
│ │
│ TESTING: │
│ ──────── │
│ □ Unit tests for conflict resolution │
│ □ Integration tests for sync flow │
│ □ E2E tests with simulated network conditions │
│ □ Chaos testing (random network drops) │
│ │
│ PERFORMANCE: │
│ ──────────── │
│ □ Pagination for initial sync │
│ □ Delta sync (only changed records) │
│ □ Compression for sync payloads │
│ □ Memory profiling during large syncs │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Summary
Offline-first isn't just a feature — it's an architectural commitment. The local database becomes your source of truth. The server becomes a sync target. Every write succeeds immediately. Every read comes from local state.
The complexity cost is real: you need sync engines, conflict resolution, operation queues, and UX that communicates state honestly. But the user experience is transformative: the app always works, changes never get lost, and "loading" states become rare.
The key principles:
- Local writes, async sync — Never block user actions on network
- Explicit sync state — Users should know what's pending
- Deterministic conflict resolution — Same inputs always produce same outputs
- Survive restarts — Pending operations must persist
- Honest UX — Never say "saved" when you mean "queued"
Build for the subway, the airplane, the spotty coffee shop WiFi. Your users will thank you.
The best offline-first app is one where users don't notice they're offline. They just keep working, and everything syncs when it can.
What did you think?