es TypeScript and a SQLite-backed local database, but the patterns apply to any persistent storage engine.
Step 1: Local Database as Source of Truth
Initialize a local database with versioned records and explicit sync metadata. Every entity must include id, updatedAt, version, and syncStatus.
import { SQLiteDatabase } from 'react-native-sqlite-storage';
interface SyncRecord {
id: string;
data: Record<string, any>;
version: number;
updatedAt: number;
syncStatus: 'pending' | 'synced' | 'conflict';
}
class LocalStore {
private db: SQLiteDatabase;
constructor(db: SQLiteDatabase) {
this.db = db;
this.initSchema();
}
private async initSchema() {
await this.db.executeSql(`
CREATE TABLE IF NOT EXISTS records (
id TEXT PRIMARY KEY,
data TEXT NOT NULL,
version INTEGER DEFAULT 1,
updatedAt INTEGER NOT NULL,
syncStatus TEXT DEFAULT 'pending'
)
`);
}
async upsert(record: SyncRecord): Promise<void> {
const json = JSON.stringify(record.data);
await this.db.executeSql(
`INSERT OR REPLACE INTO records (id, data, version, updatedAt, syncStatus)
VALUES (?, ?, ?, ?, ?)`,
[record.id, json, record.version, record.updatedAt, record.syncStatus]
);
}
async getPendingSync(): Promise<SyncRecord[]> {
const result = await this.db.executeSql(
`SELECT * FROM records WHERE syncStatus = 'pending' ORDER BY updatedAt ASC`
);
return result[0].rows.raw();
}
}
Step 2: Optimistic UI with Rollback Capability
UI mutations must apply locally before network transmission. If sync fails, the local state remains valid and can be retried or rolled back.
class SyncManager {
private queue: Map<string, SyncRecord> = new Map();
private isSyncing = false;
constructor(private local: LocalStore, private api: ApiClient) {}
async applyOptimisticUpdate(record: SyncRecord): Promise<void> {
record.syncStatus = 'pending';
record.updatedAt = Date.now();
record.version += 1;
await this.local.upsert(record);
this.queue.set(record.id, record);
this.triggerSync();
}
private async triggerSync(): Promise<void> {
if (this.isSyncing) return;
this.isSyncing = true;
try {
const pending = await this.local.getPendingSync();
for (const record of pending) {
await this.syncRecord(record);
}
} catch (err) {
console.warn('Sync batch failed, backing off', err);
} finally {
this.isSyncing = false;
}
}
private async syncRecord(record: SyncRecord): Promise<void> {
const response = await this.api.put(`/records/${record.id}`, {
body: record.data,
headers: { 'X-Record-Version': String(record.version) }
});
if (response.ok) {
await this.local.upsert({ ...record, syncStatus: 'synced' });
this.queue.delete(record.id);
} else if (response.status === 409) {
await this.handleConflict(record, response.body);
}
}
}
Step 3: Conflict Resolution Strategy
Conflict handling depends on data semantics. For single-user CRUD, last-write-wins with versioning suffices. For collaborative editing, use CRDTs (Conflict-free Replicated Data Types) or operational transforms. The example below implements versioned LWW with server reconciliation.
private async handleConflict(local: SyncRecord, serverData: any): Promise<void> {
const serverVersion = serverData.version;
if (local.version > serverVersion) {
// Local is newer, force push
await this.api.put(`/records/${local.id}`, {
body: local.data,
headers: { 'X-Force-Override': 'true' }
});
await this.local.upsert({ ...local, syncStatus: 'synced' });
} else {
// Server is newer, accept remote state
await this.local.upsert({
id: local.id,
data: serverData.data,
version: serverData.version,
updatedAt: Date.now(),
syncStatus: 'synced'
});
}
}
Architecture Decisions and Rationale
- Local-first storage: SQLite or embedded engines (WatermelonDB, RxDB, Realm) outperform in-memory caches for persistence across app kills and OS memory pressure.
- Async sync queue: Decouples UI responsiveness from network reliability. Exponential backoff with jitter prevents thundering herd issues on reconnection.
- Idempotent endpoints: Server must accept duplicate sync requests without side effects. Use
X-Record-Version or ETags to detect stale writes.
- Token refresh offline: Store short-lived access tokens with a refresh mechanism that doesn't block initial load. Defer auth refresh until network is available.
- State reconciliation over replication: Sync only deltas, not full payloads. Reduce bandwidth and conflict surface area.
Pitfall Guide
1. Treating Offline as a Binary State
Network connectivity exists on a spectrum. navigator.onLine and native reachability APIs report coarse states that don't reflect packet loss, high latency, or captive portals. Relying on binary checks causes false positives where the app believes it's online but sync fails silently.
Best practice: Implement active probing with lightweight heartbeat requests. Classify network quality as offline, degraded, or optimal based on latency and success rate, then adjust sync frequency and UI feedback accordingly.
2. Ignoring Conflict Resolution Semantics
Defaulting to last-write-wins without understanding data mutability guarantees data corruption in collaborative or multi-device scenarios. Simultaneous edits to the same field will overwrite each other unpredictably.
Best practice: Define conflict strategy per entity type. Use CRDTs for text, counters, and shared lists. Use versioned LWW for configuration and single-owner records. Never merge without explicit business rules.
3. Unbounded Local Storage Growth
Queuing every mutation without cleanup or compaction leads to storage exhaustion, especially on low-end devices. Sync queues that never prune resolved records degrade query performance and increase crash risk.
Best practice: Implement TTL-based cleanup, archive resolved sync records to cold storage, and cap queue depth. Use background compaction jobs to remove superseded versions.
4. Sync Without Idempotency
Retrying failed sync requests without idempotency keys causes duplicate creations, double-charges, or inconsistent state. Network timeouts often mask successful writes, making retries destructive.
Best practice: Generate client-side idempotency keys for every mutation. Server must store processed keys and return cached responses for duplicates. Include X-Idempotency-Key in all sync payloads.
5. Blocking the Main Thread During Sync
Synchronous database writes or network calls on the UI thread cause frame drops, ANR/Watchdog terminations, and perceived freezing. Offline-first apps must guarantee 60fps interaction regardless of sync state.
Best practice: Offload all I/O to background workers or native threads. Use optimistic updates with immediate local persistence. Queue sync operations asynchronously and debounce rapid mutations.
6. Assuming Network Availability for Authentication
Auth flows that require online token validation on launch prevent offline access entirely. Storing long-lived tokens without rotation creates security gaps.
Best practice: Cache short-lived tokens with explicit expiration. Allow offline access with cached credentials. Defer token refresh to background sync. Implement graceful degradation where read-only mode persists until auth is re-validated.
7. Skipping Offline-First Testing
Testing only under stable Wi-Fi or cellular masks sync failures, conflict edge cases, and state corruption. QA environments that simulate 100% uptime produce production systems that fail under real conditions.
Best practice: Integrate network throttling, packet loss simulation, and offline toggle into CI/CD. Write integration tests that kill sync mid-flight, simulate clock skew, and verify conflict resolution paths. Use chaos engineering for sync queues.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single-user CRUD (notes, tasks, profiles) | Versioned Last-Write-Wins | Simpler implementation, low conflict probability, fast resolution | Low dev cost, minimal server complexity |
| Collaborative editing (docs, whiteboards, shared lists) | CRDTs (Yjs, Automerge) | Guarantees convergence without central coordination, handles concurrent edits | Higher initial dev cost, requires specialized libraries |
| High-frequency telemetry/IoT streaming | Append-only log with server-side aggregation | Client generates rapid mutations; server deduplicates and compresses | Moderate client cost, high server compute for aggregation |
| Regulated/audit-heavy data (finance, healthcare) | Versioned LWW + audit trail + manual conflict review | Compliance requires deterministic resolution and human oversight | High storage cost, requires admin UI for conflict resolution |
Configuration Template
// sync.config.ts
export const SyncConfig = {
local: {
engine: 'sqlite', // or 'watermelon', 'rxdb', 'realm'
dbName: 'app_local.db',
maxQueueSize: 5000,
compactionInterval: 3600000, // 1 hour
ttlResolved: 86400000 // 24 hours
},
sync: {
endpoint: 'https://api.example.com/v1/sync',
batchSize: 50,
initialBackoff: 1000,
maxBackoff: 60000,
jitter: true,
idempotencyHeader: 'X-Idempotency-Key',
versionHeader: 'X-Record-Version',
networkProbe: {
url: 'https://api.example.com/health',
interval: 15000,
timeout: 3000,
thresholds: { degraded: 500, offline: 3 }
}
},
conflict: {
defaultStrategy: 'lww', // 'lww' | 'crdt' | 'manual'
crdtLibrary: 'yjs', // if applicable
auditLogging: true
}
};
Quick Start Guide
- Initialize local storage: Install a persistent SQLite wrapper or embedded database. Create a
records table with id, data, version, updatedAt, and syncStatus columns. Seed with schema migration on first launch.
- Wire optimistic updates: Replace direct API calls with local
upsert operations. Update UI immediately after local write. Push mutation to async sync queue with generated idempotency key and incremented version.
- Configure background sync: Implement network quality probing. Trigger sync batch when status shifts from
offline/degraded to optimal. Apply exponential backoff with jitter on failure. Mark records synced only after 2xx response.
- Add conflict handling: Implement version comparison logic. For LWW, push local if version > server version, otherwise accept remote. Log conflicts if audit mode is enabled. Test with simulated concurrent writes.
- Validate offline resilience: Kill network mid-sync. Close and reopen app. Verify pending records persist, UI reflects local state, and sync resumes automatically on reconnection. Run CI pipeline with packet loss simulation.