# Synchronization Patterns > Conflict resolution and synchronization patterns for offline-first applications. See [SKILL.md](../SKILL.md) for concepts. --- ## Pattern 18: Last-Write-Wins (LWW) Resolution The simplest conflict resolution strategy where the most recent change wins. ```typescript // sync/last-write-wins.ts interface SyncableEntity { id: string; _lastModified: number; _serverTimestamp?: number; _localVersion: string; _serverVersion?: string; _syncStatus: "synced" | "pending" | "conflicted"; } interface ConflictResult { resolved: T; winner: "local" | "server"; loser: T; } // Named constants const CLOCK_DRIFT_TOLERANCE_MS = 5000; /** * Resolves conflict using Last-Write-Wins strategy. * Server timestamp takes precedence if available, falls back to client timestamps. */ function resolveWithLWW( local: T, server: T, ): ConflictResult { // Prefer server timestamps for comparison (more reliable) const localTime = local._serverTimestamp ?? local._lastModified; const serverTime = server._serverTimestamp ?? server._lastModified; // Account for minor clock drift - if within tolerance, server wins const timeDiff = Math.abs(localTime - serverTime); const localIsNewer = localTime > serverTime + CLOCK_DRIFT_TOLERANCE_MS; if (localIsNewer) { return { resolved: { ...local, _syncStatus: "synced" as const, _serverVersion: server._serverVersion, }, winner: "local", loser: server, }; } return { resolved: { ...server, _syncStatus: "synced" as const, _localVersion: local._localVersion, }, winner: "server", loser: local, }; } export { resolveWithLWW }; export type { ConflictResult }; ``` **Why good:** Simple and predictable, handles clock drift, preserves version info from both sides --- ## Pattern 19: Field-Level Merge Merge non-conflicting field changes, only conflict on same-field edits. ```typescript // sync/field-merge.ts interface FieldChange { field: string; localValue: unknown; serverValue: unknown; localTimestamp: number; serverTimestamp: number; } interface MergeResult { merged: T; autoResolved: FieldChange[]; conflicts: FieldChange[]; } // Fields that should never be merged (sync metadata) const SYNC_METADATA_FIELDS = [ "_syncStatus", "_lastModified", "_localVersion", "_serverVersion", "_serverTimestamp", "_deletedAt", "id", ] as const; /** * Performs field-level merge, identifying conflicts only where * both local and server changed the same field. */ function mergeFields>( base: T, // Last known common state local: T, // Local changes server: T, // Server changes localTimestamp: number, serverTimestamp: number, ): MergeResult { const merged = { ...base } as T; const autoResolved: FieldChange[] = []; const conflicts: FieldChange[] = []; const allFields = new Set([...Object.keys(local), ...Object.keys(server)]); for (const field of allFields) { // Skip sync metadata if ( SYNC_METADATA_FIELDS.includes( field as (typeof SYNC_METADATA_FIELDS)[number], ) ) { continue; } const baseValue = base[field]; const localValue = local[field]; const serverValue = server[field]; const localChanged = !deepEqual(baseValue, localValue); const serverChanged = !deepEqual(baseValue, serverValue); if (!localChanged && !serverChanged) { // No changes continue; } if (localChanged && !serverChanged) { // Only local changed - use local (merged as Record)[field] = localValue; autoResolved.push({ field, localValue, serverValue, localTimestamp, serverTimestamp, }); } else if (!localChanged && serverChanged) { // Only server changed - use server (merged as Record)[field] = serverValue; autoResolved.push({ field, localValue, serverValue, localTimestamp, serverTimestamp, }); } else if (deepEqual(localValue, serverValue)) { // Both changed to same value - no conflict (merged as Record)[field] = localValue; } else { // True conflict - both changed to different values conflicts.push({ field, localValue, serverValue, localTimestamp, serverTimestamp, }); // Default to server for merged result, but flag as conflict (merged as Record)[field] = serverValue; } } return { merged, autoResolved, conflicts }; } // Simple deep equality check function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (typeof a !== typeof b) return false; if (a === null || b === null) return a === b; if (typeof a !== "object") return a === b; const aObj = a as Record; const bObj = b as Record; const aKeys = Object.keys(aObj); const bKeys = Object.keys(bObj); if (aKeys.length !== bKeys.length) return false; return aKeys.every((key) => deepEqual(aObj[key], bObj[key])); } export { mergeFields }; export type { FieldChange, MergeResult }; ``` **Why good:** Only conflicts where both sides changed the same field, preserves non-conflicting changes from both sides, returns structured conflict info for UI --- ## Pattern 20: Conflict Resolution UI Present conflicts to users and allow manual resolution. ```tsx // components/conflict-resolver.tsx import { useState, type ReactNode } from "react"; import type { FieldChange, MergeResult } from "../sync/field-merge"; interface ConflictResolverProps { entityName: string; localEntity: T; serverEntity: T; conflicts: FieldChange[]; onResolve: (resolved: T) => Promise; onCancel: () => void; renderField?: (field: string, value: unknown) => ReactNode; } type Resolution = "local" | "server"; function ConflictResolver>({ entityName, localEntity, serverEntity, conflicts, onResolve, onCancel, renderField = (field, value) => JSON.stringify(value), }: ConflictResolverProps) { const [resolutions, setResolutions] = useState>( new Map(), ); const [isSubmitting, setIsSubmitting] = useState(false); const allResolved = conflicts.every((c) => resolutions.has(c.field)); const handleFieldResolution = (field: string, choice: Resolution) => { setResolutions((prev) => new Map(prev).set(field, choice)); }; const handleSubmit = async () => { if (!allResolved) return; setIsSubmitting(true); try { // Build resolved entity const resolved = { ...serverEntity }; for (const [field, choice] of resolutions) { if (choice === "local") { (resolved as Record)[field] = localEntity[field]; } } await onResolve(resolved as T); } finally { setIsSubmitting(false); } }; return (

Resolve Sync Conflict

The {entityName} was modified both locally and on the server. Choose which version to keep for each field.

{conflicts.map((conflict) => (

{formatFieldName(conflict.field)}

Choose version for {conflict.field}
))}
{!allResolved && ( Please resolve all {conflicts.length - resolutions.size} remaining conflicts )}
); } function formatFieldName(field: string): string { return field .replace(/([A-Z])/g, " $1") .replace(/^./, (str) => str.toUpperCase()) .trim(); } function formatTimestamp(ts: number): string { return new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short", }).format(new Date(ts)); } export { ConflictResolver }; export type { ConflictResolverProps }; ``` **Why good:** Accessible with proper ARIA, per-field resolution, disabled submit until all resolved, custom field rendering support --- ## Pattern 21: Version Vector for Causality Track concurrent modifications using version vectors instead of timestamps. ```typescript // sync/version-vector.ts /** * A version vector tracks the "logical clock" of each node/client * that has modified an entity. Enables detecting true concurrent * modifications vs sequential updates. */ type VersionVector = Record; interface VersionedEntity { id: string; _versionVector: VersionVector; _nodeId: string; // This client's node ID } // Get or create persistent node ID for this client function getNodeId(): string { const STORAGE_KEY = "offline-first-node-id"; let nodeId = localStorage.getItem(STORAGE_KEY); if (!nodeId) { nodeId = crypto.randomUUID(); localStorage.setItem(STORAGE_KEY, nodeId); } return nodeId; } const NODE_ID = getNodeId(); /** * Increment the local node's version in the vector */ function incrementVersion(vector: VersionVector): VersionVector { return { ...vector, [NODE_ID]: (vector[NODE_ID] ?? 0) + 1, }; } /** * Merge two version vectors, taking max of each node's version */ function mergeVectors(a: VersionVector, b: VersionVector): VersionVector { const merged: VersionVector = { ...a }; for (const [node, version] of Object.entries(b)) { merged[node] = Math.max(merged[node] ?? 0, version); } return merged; } type CompareResult = "before" | "after" | "concurrent" | "equal"; /** * Compare two version vectors to determine causality: * - 'before': a happened before b * - 'after': a happened after b * - 'concurrent': a and b are concurrent (conflict) * - 'equal': a and b are identical */ function compareVectors(a: VersionVector, b: VersionVector): CompareResult { let aBeforeB = true; let bBeforeA = true; const allNodes = new Set([...Object.keys(a), ...Object.keys(b)]); for (const node of allNodes) { const aVersion = a[node] ?? 0; const bVersion = b[node] ?? 0; if (aVersion > bVersion) { bBeforeA = false; } if (bVersion > aVersion) { aBeforeB = false; } } if (aBeforeB && bBeforeA) { return "equal"; } if (aBeforeB) { return "before"; } if (bBeforeA) { return "after"; } return "concurrent"; } /** * Check if entity needs sync based on version comparison */ function needsSync( local: VersionedEntity, server: VersionedEntity, ): { needsUpload: boolean; needsDownload: boolean; hasConflict: boolean; } { const comparison = compareVectors( local._versionVector, server._versionVector, ); return { needsUpload: comparison === "after", needsDownload: comparison === "before", hasConflict: comparison === "concurrent", }; } export { getNodeId, NODE_ID, incrementVersion, mergeVectors, compareVectors, needsSync, }; export type { VersionVector, VersionedEntity, CompareResult }; ``` **Why good:** Detects true concurrent edits vs sequential updates, no clock synchronization needed, deterministic conflict detection, persistent node ID --- ## Pattern 22: Delta Sync Protocol Sync only changed items since last sync using server-provided cursors. ```typescript // sync/delta-sync.ts interface SyncCursor { collection: string; lastSyncTimestamp: number; lastServerId?: string; // For pagination } interface DeltaSyncResult { items: T[]; hasMore: boolean; nextCursor: SyncCursor; serverTimestamp: number; } // Named constants const DEFAULT_BATCH_SIZE = 100; const CURSOR_STORAGE_KEY = "sync-cursors"; class DeltaSyncManager { private cursors: Map; constructor() { this.cursors = this.loadCursors(); } private loadCursors(): Map { try { const stored = localStorage.getItem(CURSOR_STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored) as Record; return new Map(Object.entries(parsed)); } } catch { console.warn("Failed to load sync cursors"); } return new Map(); } private saveCursors(): void { const obj = Object.fromEntries(this.cursors); localStorage.setItem(CURSOR_STORAGE_KEY, JSON.stringify(obj)); } getCursor(collection: string): SyncCursor { return ( this.cursors.get(collection) ?? { collection, lastSyncTimestamp: 0, } ); } updateCursor(cursor: SyncCursor): void { this.cursors.set(cursor.collection, cursor); this.saveCursors(); } async fetchDelta( collection: string, fetcher: ( cursor: SyncCursor, batchSize: number, ) => Promise>, options: { batchSize?: number } = {}, ): Promise { const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE; const allItems: T[] = []; let cursor = this.getCursor(collection); // Fetch all pages while (true) { const result = await fetcher(cursor, batchSize); allItems.push(...result.items); // Update cursor this.updateCursor(result.nextCursor); cursor = result.nextCursor; if (!result.hasMore) { break; } } return allItems; } resetCursor(collection: string): void { this.cursors.delete(collection); this.saveCursors(); } } // Example API fetcher implementation async function fetchTodosDelta( cursor: SyncCursor, batchSize: number, ): Promise> { const params = new URLSearchParams({ since: cursor.lastSyncTimestamp.toString(), limit: batchSize.toString(), ...(cursor.lastServerId && { after: cursor.lastServerId }), }); const response = await fetch(`/api/todos/sync?${params}`); if (!response.ok) { throw new Error(`Sync failed: ${response.status}`); } const data = await response.json(); return { items: data.items, hasMore: data.hasMore, serverTimestamp: data.serverTimestamp, nextCursor: { collection: "todos", lastSyncTimestamp: data.serverTimestamp, lastServerId: data.items.length > 0 ? data.items[data.items.length - 1].id : cursor.lastServerId, }, }; } export { DeltaSyncManager, fetchTodosDelta }; export type { SyncCursor, DeltaSyncResult }; ``` **Why good:** Only fetches changes since last sync (efficient), paginated for large datasets, cursor persistence survives app restarts --- ## Pattern 23: Background Sync with Service Worker Queue sync operations for reliable background execution. **IMPORTANT: Browser Support Limitation** The Background Sync API is **experimental** and only supported in Chrome/Edge (Chromium-based browsers). Firefox and Safari do NOT support this API. Always implement a fallback using the `online` event listener. **Status:** Experimental - HTTPS required, Service Worker dependent, specification still under development. ```typescript // service-worker/sync-handler.ts // This code runs in the service worker context interface SyncTag { tag: string; collection: string; } const SYNC_TAG_PREFIX = "sync-"; // Register for background sync async function registerBackgroundSync(collection: string): Promise { if (!("sync" in self.registration)) { console.warn("Background sync not supported"); return; } const tag = `${SYNC_TAG_PREFIX}${collection}`; try { await self.registration.sync.register(tag); } catch { // Background sync registration failed - fallback will handle sync } } // Handle sync event in service worker self.addEventListener("sync", (event: SyncEvent) => { if (!event.tag.startsWith(SYNC_TAG_PREFIX)) return; const collection = event.tag.slice(SYNC_TAG_PREFIX.length); event.waitUntil( processSyncQueue(collection).catch((error) => { console.error(`Background sync failed for ${collection}:`, error); // Return rejected promise to retry throw error; }), ); }); async function processSyncQueue(collection: string): Promise { // Open IndexedDB from service worker const db = await openDatabase(); // Get pending operations for this collection const operations = await db.getAll("syncQueue"); const collectionOps = operations.filter((op) => op.collection === collection); // Process each operation for (const op of collectionOps) { try { await executeOperation(op); await db.delete("syncQueue", op.id); } catch (error) { // Update retry count await db.put("syncQueue", { ...op, retryCount: op.retryCount + 1, lastError: error instanceof Error ? error.message : "Unknown", }); // Re-throw to signal failure to sync manager if (op.retryCount >= 4) { // Max retries exceeded - leave in queue for manual handling continue; } throw error; } } } async function executeOperation(op: QueuedOperation): Promise { const methods: Record = { CREATE: "POST", UPDATE: "PUT", DELETE: "DELETE", UPSERT: "PUT", }; const response = await fetch(`/api/${op.collection}`, { method: methods[op.type], headers: { "Content-Type": "application/json" }, body: JSON.stringify(op.data), }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } } // Export for TypeScript (self.registration.sync types) declare global { interface ServiceWorkerRegistration { readonly sync: SyncManager; } interface SyncManager { register(tag: string): Promise; } interface SyncEvent extends ExtendableEvent { readonly tag: string; } } export { registerBackgroundSync }; ``` ### Client-Side Registration ```typescript // client/register-sync.ts async function requestSyncWhenOnline(collection: string): Promise { if (!navigator.onLine) { // Will sync when online via background sync or fallback return; } // If online, trigger immediate sync const registration = await navigator.serviceWorker.ready; if ("sync" in registration) { await registration.sync.register(`sync-${collection}`); } else { // Fallback: sync immediately without service worker await syncCollection(collection); } } // Fallback for browsers without Background Sync API (Firefox, Safari) function setupSyncFallback(syncFn: () => Promise): void { window.addEventListener("online", async () => { try { await syncFn(); } catch (error) { console.error("Sync fallback failed:", error); } }); } export { requestSyncWhenOnline, setupSyncFallback }; ``` **Why good:** Sync continues even if app is closed (Chrome/Edge only), browser retries automatically on failure, queue persisted in IndexedDB, graceful fallback for unsupported browsers **Browser Support:** - Chrome/Edge: Full Background Sync support - Firefox/Safari: Must use `online` event fallback --- ## Pattern 24: Pull-Push Sync Strategy Full bidirectional sync with server as source of truth for conflicts. ```typescript // sync/pull-push-sync.ts interface SyncResult { uploaded: number; downloaded: number; conflicts: number; errors: string[]; } // Named constants const SYNC_BATCH_SIZE = 50; class PullPushSync { constructor( private readonly localDb: LocalTable, private readonly apiEndpoint: string, private readonly conflictResolver: ConflictResolver, ) {} async sync(): Promise { const result: SyncResult = { uploaded: 0, downloaded: 0, conflicts: 0, errors: [], }; try { // Step 1: Pull - fetch server changes first const serverChanges = await this.pull(); result.downloaded = serverChanges.length; // Step 2: Merge - apply server changes, detect conflicts const conflicts = await this.merge(serverChanges); result.conflicts = conflicts.length; // Step 3: Push - upload local changes const uploaded = await this.push(); result.uploaded = uploaded; } catch (error) { result.errors.push( error instanceof Error ? error.message : "Unknown error", ); } return result; } private async pull(): Promise { // Get last sync timestamp const lastSync = await this.getLastSyncTimestamp(); const response = await fetch( `${this.apiEndpoint}/changes?since=${lastSync}`, ); if (!response.ok) { throw new Error(`Pull failed: ${response.status}`); } const data = await response.json(); return data.items as T[]; } private async merge(serverItems: T[]): Promise { const conflicts: T[] = []; for (const serverItem of serverItems) { const localItem = await this.localDb.get(serverItem.id); if (!localItem) { // New item from server await this.localDb.put({ ...serverItem, _syncStatus: "synced", }); continue; } if (localItem._syncStatus === "synced") { // No local changes - just update await this.localDb.put({ ...serverItem, _syncStatus: "synced", }); continue; } // Local has changes - check for conflict if (serverItem._serverVersion !== localItem._serverVersion) { // True conflict - both changed const resolved = await this.conflictResolver.resolve( localItem, serverItem, ); if (resolved) { await this.localDb.put(resolved); } else { // Mark as conflicted for manual resolution await this.localDb.put({ ...localItem, _syncStatus: "conflicted", }); conflicts.push(localItem); } } else { // Server version matches - local changes are newer // Keep local, will push in next step } } return conflicts; } private async push(): Promise { const pendingItems = await this.localDb.getByStatus("pending"); let uploaded = 0; // Batch uploads for (let i = 0; i < pendingItems.length; i += SYNC_BATCH_SIZE) { const batch = pendingItems.slice(i, i + SYNC_BATCH_SIZE); const response = await fetch(`${this.apiEndpoint}/batch`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: batch }), }); if (!response.ok) { throw new Error(`Push failed: ${response.status}`); } const result = await response.json(); // Update local items with server versions for (const item of result.items) { await this.localDb.put({ ...item, _syncStatus: "synced", }); uploaded++; } } return uploaded; } private async getLastSyncTimestamp(): Promise { const key = `lastSync:${this.apiEndpoint}`; const stored = localStorage.getItem(key); return stored ? parseInt(stored, 10) : 0; } } export { PullPushSync }; export type { SyncResult }; ``` **Why good:** Full bidirectional sync, batched uploads for efficiency, clear conflict handling, preserves both versions for manual resolution --- ## Pattern 25: Sync Status Indicators Visual feedback for sync state at both entity and app levels. ```tsx // components/sync-status-indicator.tsx type EntitySyncStatus = "synced" | "pending" | "conflicted" | "error"; interface EntitySyncIndicatorProps { status: EntitySyncStatus; lastSynced?: number; size?: "sm" | "md"; } function EntitySyncIndicator({ status, lastSynced, size = "sm", }: EntitySyncIndicatorProps) { const labels: Record = { synced: "Synced", pending: "Saving...", conflicted: "Conflict detected", error: "Sync failed", }; const icons: Record = { synced: "✓", pending: "↻", conflicted: "⚠", error: "✕", }; return ( {status === "pending" && ( Saving changes... )} ); } // App-level sync indicator interface AppSyncStatusProps { isOnline: boolean; pendingCount: number; lastSyncTime: number | null; onForceSync?: () => void; isSyncing?: boolean; } function AppSyncStatus({ isOnline, pendingCount, lastSyncTime, onForceSync, isSyncing = false, }: AppSyncStatusProps) { return (
{!isOnline ? ( Offline - changes saved locally ) : pendingCount > 0 ? ( {isSyncing ? "Syncing..." : `${pendingCount} changes pending`} ) : ( All changes synced {lastSyncTime && ( )} )} {isOnline && pendingCount > 0 && !isSyncing && onForceSync && ( )}
); } const MS_PER_MINUTE = 60_000; const MS_PER_HOUR = 3_600_000; const MS_PER_DAY = 86_400_000; const MINUTES_PER_HOUR = 60; const HOURS_PER_DAY = 24; function formatRelativeTime(timestamp: number): string { const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); const diff = timestamp - Date.now(); const diffMinutes = Math.round(diff / MS_PER_MINUTE); const diffHours = Math.round(diff / MS_PER_HOUR); const diffDays = Math.round(diff / MS_PER_DAY); if (Math.abs(diffMinutes) < MINUTES_PER_HOUR) { return rtf.format(diffMinutes, "minute"); } if (Math.abs(diffHours) < HOURS_PER_DAY) { return rtf.format(diffHours, "hour"); } return rtf.format(diffDays, "day"); } export { EntitySyncIndicator, AppSyncStatus }; export type { EntitySyncStatus, EntitySyncIndicatorProps, AppSyncStatusProps }; ``` **Why good:** Semantic data attributes for styling, accessible labels, relative time formatting, force sync option for users