ins optimal due to lower overhead. The "WOW" factor is realizing that CRDTs shift complexity from runtime merge logic to compile-time type safety, reducing long-term maintenance costs in collaborative applications.
Core Solution
Implementing offline-first architecture requires a layered approach: local storage selection, synchronization engine design, conflict resolution strategy, and UI patterns for optimistic updates.
Step-by-Step Technical Implementation
-
Select Local Data Store:
Choose a database that supports encryption, background access, and efficient querying.
- React Native: WatermelonDB, RxDB, or SQLite via
react-native-sqlite-storage.
- Flutter: Isar, Hive, or SQLite.
- Native: Core Data (iOS), Room (Android).
- Rationale: WatermelonDB is preferred for high-performance sync due to its lazy-loading architecture and native bindings.
-
Design Sync Schema:
Define a schema that includes metadata for synchronization:
id: Unique identifier (UUID v4).
version: Integer or CRDT clock.
deleted_at: Soft delete timestamp.
updated_at: Server timestamp for delta queries.
sync_status: Enum (pending, synced, failed).
-
Implement Sync Engine:
Build a bidirectional sync loop that handles push and pull operations.
- Push: Query local records with
sync_status = 'pending'. Send mutations to server. Handle idempotency keys to prevent duplicate writes.
- Pull: Fetch changes since last sync token. Apply deltas to local DB.
- Conflict Resolution: If using CRDTs, merge locally. If using LWW, compare timestamps.
-
Optimistic UI Pattern:
Update the UI immediately upon user action, then queue the mutation for sync.
- Show "Pending" state indicators.
- Implement rollback logic if sync fails.
Code Examples
Sync Hook with Optimistic Updates (TypeScript):
import { db } from './database';
import { syncEngine } from './sync';
interface SyncRecord {
id: string;
version: number;
data: Record<string, any>;
syncStatus: 'pending' | 'synced' | 'failed';
}
export function useSync<T extends SyncRecord>(collection: string) {
// Optimistic update wrapper
async function optimisticUpdate(id: string, update: Partial<T>) {
const record = await db.get<T>(collection, id);
if (!record) throw new Error('Record not found');
// 1. Update local DB immediately
const updatedRecord = { ...record, ...update, syncStatus: 'pending' };
await db.update(collection, updatedRecord);
// 2. Queue for sync
const syncAction = async () => {
try {
await syncEngine.pushMutation(collection, updatedRecord);
await db.update(collection, { ...updatedRecord, syncStatus: 'synced' });
} catch (error) {
// 3. Rollback on failure
await db.update(collection, { ...record, syncStatus: 'failed' });
console.error('Sync failed:', error);
}
};
// Execute sync in background
syncEngine.queue(syncAction);
}
return { optimisticUpdate };
}
CRDT Implementation (OR-Set for Tags):
// Operation-based CRDT for a set of tags
export class ORSet {
private elements: Map<string, Set<string>> = new Map();
private tombstones: Set<string> = new Set();
add(tag: string, context: string) {
if (!this.tombstones.has(tag)) {
const ctx = this.elements.get(tag) || new Set();
ctx.add(context);
this.elements.set(tag, ctx);
}
}
remove(tag: string, context: string) {
const ctx = this.elements.get(tag);
if (ctx) {
ctx.delete(context);
if (ctx.size === 0) {
this.elements.delete(tag);
this.tombstones.add(tag);
}
}
}
merge(other: ORSet) {
for (const [tag, ctx] of other.elements) {
const localCtx = this.elements.get(tag) || new Set();
for (const c of ctx) {
localCtx.add(c);
}
this.elements.set(tag, localCtx);
}
for (const t of other.tombstones) {
this.tombstones.add(t);
}
}
value(): string[] {
return Array.from(this.elements.keys());
}
}
Architecture Decisions and Rationale
- Local-First vs. Server-First: Local-first ensures the app remains functional during network partitions. The server acts as a backup and multi-device sync hub, not the primary data source.
- CRDTs vs. Vector Clocks: CRDTs are preferred for collaborative features because they guarantee convergence without centralized coordination. Vector clocks require merge logic on the server, which can become a bottleneck and single point of failure.
- Background Sync: Use platform-specific background tasks (iOS Background Fetch, Android WorkManager, or Service Workers for PWA) to trigger sync when connectivity is restored. Avoid aggressive polling; use exponential backoff for retries.
Pitfall Guide
-
Treating Offline as a Boolean State:
- Mistake: Checking
navigator.onLine and blocking UI.
- Reality: Connectivity is intermittent. The app must function with degraded performance, not just offline/online states. Use progressive enhancement and queue mechanisms.
-
Ignoring Storage Quotas:
- Mistake: Storing unlimited data locally without cleanup.
- Impact: Mobile OSes may purge app data or kill processes if storage limits are exceeded. Implement TTL policies, archive old records, and monitor storage usage.
-
Infinite Sync Loops:
- Mistake: Sync logic triggers updates that generate new sync events.
- Solution: Use idempotency keys and version checks. Ensure that applying a remote change does not trigger a local update that is pushed back.
-
LWW Data Loss in Collaborative Apps:
- Mistake: Using timestamps to resolve conflicts in multi-user editing.
- Impact: Simultaneous edits result in one user's changes being silently discarded. Use CRDTs or operational transforms for collaborative data.
-
Blocking the Main Thread:
- Mistake: Performing heavy DB writes or JSON parsing on the UI thread.
- Solution: Offload sync and DB operations to web workers (PWA) or background threads (Native/RN). Use lazy loading for large datasets.
-
Security of Local Data:
- Mistake: Storing sensitive data in plain text.
- Solution: Encrypt PII at rest using platform keychains (iOS Keychain, Android EncryptedSharedPreferences) or SQLCipher. Decrypt only in memory when needed.
-
Time Synchronization Issues:
- Mistake: Relying on device clock for conflict resolution.
- Impact: Drift between device and server clocks causes incorrect LWW decisions. Use server timestamps for authoritative time or CRDTs that are clock-independent.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single-User App | LWW with Delta Sync | Conflicts are impossible; LWW is simpler and has lower payload size. | Low |
| Multi-User Collaborative | CRDTs (OR-Set/LWW-Register) | Guarantees convergence without server arbitration; prevents data loss. | High (Dev Effort) |
| High Write Frequency | CRDTs or Operational Transform | Reduces sync conflicts and merge overhead; handles rapid updates gracefully. | Medium |
| Low Bandwidth Regions | Delta Sync with Compression | Minimizes payload size; only transmits changes, not full snapshots. | Low |
| Regulatory Compliance | Local Encryption + Secure Sync | Ensures PII is encrypted at rest and in transit; meets GDPR/HIPAA requirements. | Medium |
| Real-Time Presence | WebSockets + CRDTs | Provides instant updates; CRDTs handle offline merges seamlessly. | High |
Configuration Template
Sync Configuration (TypeScript):
// sync-config.ts
export const syncConfig = {
// Retry strategy
retry: {
maxAttempts: 5,
baseDelay: 1000, // ms
maxDelay: 30000, // ms
backoffFactor: 2,
},
// Sync intervals
intervals: {
foreground: 30000, // ms
background: 300000, // ms
onConnectivityChange: true,
},
// Conflict resolution
conflictResolution: {
strategy: 'crdt', // 'lww' | 'crdt' | 'server-wins'
crdtTypes: {
tags: 'or-set',
notes: 'lww-register',
},
},
// Storage limits
storage: {
maxRecords: 10000,
ttlDays: 90,
encryptFields: ['email', 'phone', 'ssn'],
},
// Idempotency
idempotency: {
enabled: true,
keyPrefix: 'sync_',
expiryHours: 24,
},
};
Quick Start Guide
-
Initialize Local DB:
npm install @nozbe/watermelondb @nozbe/watermelondb/adapters/sqlite
Create a schema with sync_status and version fields. Initialize the adapter with encryption.
-
Set Up Sync Engine:
Implement a SyncService class that queries pending records, sends them to the API, and applies remote changes. Use the syncConfig template for retry and conflict settings.
-
Add Optimistic Updates:
Wrap UI actions in optimisticUpdate functions. Ensure the UI reflects the pending state and handles rollback on error.
-
Configure Background Sync:
For React Native, use react-native-background-fetch. For PWA, register a Service Worker with sync event listeners. Trigger sync on online events and periodic intervals.
-
Test Offline Scenarios:
Use Flipper or Chrome DevTools to simulate offline mode. Verify that the app remains functional, queues mutations, and syncs correctly when connectivity is restored. Check for data integrity and conflict resolution behavior.