# Offline-First Reference > Decision frameworks, anti-patterns, and troubleshooting for offline-first applications. See [SKILL.md](SKILL.md) for concepts. --- ## Decision Frameworks ### Framework 1: Storage Strategy Selection ``` What type of data are you storing? ├─ Structured data (entities, records) │ ├─ Need reactive queries? │ │ └─ YES → Dexie.js 4.x with useLiveQuery │ │ └─ NO → idb 8.x (lighter weight, ~1.2KB) │ └─ Complex relational queries? │ └─ YES → Consider SQLite via WebAssembly or a sync-capable DB (e.g. PouchDB, RxDB) │ └─ NO → Dexie.js or idb ├─ Simple key-value data (< 5MB, not critical) │ └─ localStorage (sync, blocks UI) or idb-keyval (~295 bytes) ├─ Large files (images, videos) │ └─ Cache API, OPFS, or File System Access API └─ Sensitive data └─ Web Crypto API + IndexedDB (encrypt before storing) Storage Limits: ├─ localStorage: 5MB per origin (sync, blocks UI) ├─ IndexedDB: 50% of available disk space (async) ├─ Safari: 7-day eviction on script-writable storage └─ Request persistent storage to prevent eviction ``` ### Framework 2: Conflict Resolution Strategy ``` What type of data is conflicting? ├─ Independent values (toggle, counter) │ └─ Last-Write-Wins (LWW) ├─ Collaborative text (documents) │ └─ CRDT library (e.g. Yjs, Automerge) ├─ User preferences │ └─ Field-Level Merge ├─ Business-critical data │ └─ Manual Resolution with UI └─ Ordered lists └─ Fractional Indexing + LWW ``` ### Framework 3: Sync Frequency ``` How critical is data freshness? ├─ Real-time (< 1s) │ └─ WebSocket + local cache (not offline-first) ├─ Near real-time (1-30s) │ └─ Polling + optimistic updates ├─ Background sync (30s - 5min) │ └─ Service Worker Background Sync (Chrome/Edge only - experimental) │ └─ Fallback: online event listener + manual sync └─ Manual/on-demand └─ User-triggered sync button Note: Background Sync API is experimental - only Chrome/Edge support it. Always implement fallback sync using 'online' event listener. ``` ### Framework 4: Offline Capability Scope ``` What offline capabilities does the app need? ├─ Read-only offline │ └─ Service Worker cache-first + IndexedDB cache ├─ Full CRUD offline │ └─ IndexedDB + Sync Queue + Conflict Resolution ├─ Collaborative offline │ └─ CRDTs + IndexedDB + Merge strategies └─ No offline needed └─ Don't add complexity - use network-only ``` --- ## Anti-Patterns ### Anti-Pattern 1: Hard Deletes ```typescript // ❌ Bad - Hard delete loses data async function deleteTodo(id: string): Promise { await db.todos.delete(id); await syncQueue.enqueue({ type: "DELETE", data: { id }, }); } // Problem: If sync fails, server still has the item // Next pull will "resurrect" the deleted item ``` ```typescript // ✅ Good - Soft delete with tombstone async function deleteTodo(id: string): Promise { await db.todos.update(id, { _deletedAt: Date.now(), _syncStatus: "pending", _lastModified: Date.now(), }); await syncQueue.enqueue({ type: "DELETE", data: { id, _deletedAt: Date.now() }, }); } ``` **Why bad:** Hard deletes cause resurrection bugs when sync fails or is delayed. --- ### Anti-Pattern 2: Trusting navigator.onLine ```typescript // ❌ Bad - navigator.onLine is unreliable function isOnline(): boolean { return navigator.onLine; } // Can return true when: // - Connected to WiFi with no internet // - Behind captive portal // - VPN disconnected // - Severely degraded connection ``` ```typescript // ✅ Good - Verify with actual request async function checkConnectivity(): Promise { try { const response = await fetch("/api/health", { method: "HEAD", cache: "no-store", }); return response.ok; } catch { return false; } } ``` **Why bad:** navigator.onLine only checks if there's a network interface, not actual internet connectivity. --- ### Anti-Pattern 3: Sync Without Retry Logic ```typescript // ❌ Bad - No retry on failure async function syncItem(item: Todo): Promise { const response = await fetch("/api/todos", { method: "POST", body: JSON.stringify(item), }); if (!response.ok) { throw new Error("Sync failed"); // Lost forever } } ``` ```typescript // ✅ Good - Queue with exponential backoff async function syncItem(item: Todo): Promise { await syncQueue.enqueue({ type: "UPSERT", data: item, timestamp: Date.now(), }); // Queue handles retries with exponential backoff } ``` **Why bad:** Transient failures cause permanent data loss without retry logic. --- ### Anti-Pattern 4: Timestamp-Only Conflict Detection ```typescript // ❌ Bad - Relies only on timestamps function hasConflict(local: Todo, server: Todo): boolean { return local._lastModified !== server._lastModified; } // Problems: // - Clock drift between devices // - Simultaneous edits at "same" time // - No way to detect concurrent vs sequential changes ``` ```typescript // ✅ Good - Use version vectors function hasConflict(local: Todo, server: Todo): boolean { const comparison = compareVectors( local._versionVector, server._versionVector, ); return comparison === "concurrent"; } ``` **Why bad:** Timestamps can't distinguish concurrent edits from sequential ones. --- ### Anti-Pattern 5: Blocking UI on Sync ```typescript // ❌ Bad - Block UI while syncing async function saveTodo(todo: Todo): Promise { setLoading(true); // User waits... const response = await fetch("/api/todos", { method: "POST", body: JSON.stringify(todo), }); if (response.ok) { await db.todos.put(todo); } setLoading(false); } ``` ```typescript // ✅ Good - Local-first, non-blocking async function saveTodo(todo: Todo): Promise { // Immediate local save await db.todos.put({ ...todo, _syncStatus: "pending", }); // Background sync (non-blocking) syncQueue.enqueue({ type: "UPSERT", data: todo }); } ``` **Why bad:** Users wait for network operations, defeating the purpose of offline-first. --- ### Anti-Pattern 6: Missing Offline Detection in UI ```typescript // ❌ Bad - No indication of sync state function TodoItem({ todo }: { todo: Todo }) { return (
  • {todo.title}
  • ); } // Users don't know: // - If their changes are saved // - If changes will sync // - If there's a conflict ``` ```typescript // ✅ Good - Clear sync indicators function TodoItem({ todo }: { todo: Todo }) { return (
  • {todo.title}
  • ); } ``` **Why bad:** Users lose trust when they can't see sync status. --- ### Anti-Pattern 7: Unbounded Sync Queue ```typescript // ❌ Bad - No limits on queue async function enqueue(operation: Operation): Promise { await db.syncQueue.add(operation); // Queue can grow infinitely if offline for long time } ``` ```typescript // ✅ Good - Queue management with limits const MAX_QUEUE_SIZE = 1000; const QUEUE_PRUNE_THRESHOLD = 800; async function enqueue(operation: Operation): Promise { const queueSize = await db.syncQueue.count(); if (queueSize >= MAX_QUEUE_SIZE) { // Prune oldest synced operations await pruneOldOperations(QUEUE_PRUNE_THRESHOLD); } await db.syncQueue.add(operation); } ``` **Why bad:** Unbounded queues can exhaust storage or cause OOM during sync. --- ### Anti-Pattern 8: Ignoring Storage Quotas ```typescript // ❌ Bad - No quota handling async function saveData(key: string, data: unknown): Promise { await db.cache.put(key, data); // May throw QuotaExceededError } ``` ```typescript // ✅ Good - Handle quota proactively async function saveData(key: string, data: unknown): Promise { const storageInfo = await getStorageInfo(); if (storageInfo.status === "critical") { await performStorageCleanup(); } try { await db.cache.put(key, data); } catch (error) { if (error.name === "QuotaExceededError") { await performStorageCleanup(); await db.cache.put(key, data); // Retry once } throw error; } } ``` **Why bad:** QuotaExceededError crashes the app without warning. --- ### Anti-Pattern 9: Awaiting Non-IndexedDB Operations Mid-Transaction ```typescript // ❌ Bad - Awaiting fetch inside transaction async function updateWithServerData(id: string): Promise { const tx = db.transaction("todos", "readwrite"); const store = tx.objectStore("todos"); // Transaction will auto-close during this await! const serverData = await fetch(`/api/todos/${id}`).then((r) => r.json()); // TRANSACTION_INACTIVE_ERR - transaction already closed await store.put(serverData); } ``` ```typescript // ✅ Good - Fetch first, then transaction async function updateWithServerData(id: string): Promise { // Fetch outside transaction const serverData = await fetch(`/api/todos/${id}`).then((r) => r.json()); // Short-lived transaction const tx = db.transaction("todos", "readwrite"); const store = tx.objectStore("todos"); await store.put(serverData); await tx.done; } ``` **Why bad:** IndexedDB transactions auto-close when control returns to the event loop without pending requests. Any `await` on non-IndexedDB operations (fetch, setTimeout, etc.) causes the transaction to close, resulting in `TRANSACTION_INACTIVE_ERR`. --- ## Troubleshooting Guide ### Issue: Data "Disappears" After Sync **Symptoms:** - User creates item offline - After coming online and syncing, item is gone **Causes:** 1. Server rejects item (validation, auth) 2. Conflict resolved in favor of server 3. Hard delete on another device synced over **Solutions:** 1. Check server response for errors, log sync failures 2. Implement user-visible conflict resolution 3. Use soft deletes with tombstone retention --- ### Issue: Duplicate Items After Sync **Symptoms:** - Same item appears multiple times after sync - Duplicates have different IDs **Causes:** 1. Item created offline, synced, then local ID not updated to server ID 2. Multiple devices create "same" item offline **Solutions:** 1. Server should return created ID, update local record 2. Use client-generated UUIDs instead of server auto-increment 3. Implement deduplication based on content hash --- ### Issue: Sync Queue Never Empties **Symptoms:** - Pending count never goes to zero - Same operations retry indefinitely **Causes:** 1. Server consistently rejects operations 2. No max retry limit 3. Operations queued faster than processed **Solutions:** 1. Log and surface server errors to user 2. Implement max retry with dead-letter queue 3. Batch operations, implement rate limiting --- ### Issue: Optimistic Update Shows Then Reverts **Symptoms:** - UI updates immediately - Then reverts after network call fails **Causes:** 1. Server validation fails 2. Conflict with newer server data 3. Authorization error **Solutions:** 1. Validate locally before optimistic update 2. Show "pending" state, only confirm after sync 3. Handle auth errors gracefully (re-authenticate) --- ### Issue: IndexedDB QuotaExceededError **Symptoms:** - App crashes with QuotaExceededError - Storage full error messages **Causes:** 1. Too much data cached 2. Tombstones never cleaned up 3. Sync queue growing unbounded **Solutions:** 1. Request persistent storage permission 2. Implement tombstone cleanup (Pattern 12) 3. Monitor storage with quota management (Pattern 17) 4. Implement data eviction strategy (LRU cache) --- ### Issue: Safari Data Eviction (7-Day Cap) **Symptoms:** - Data disappears after ~7 days on iOS Safari - Users report losing offline data on Safari **Causes:** Safari (iOS 13.4+, macOS Safari 13.1+) enforces a 7-day cap on all script-writable storage including IndexedDB, service worker registration, and Cache API if the user doesn't interact with the site. **Solutions:** 1. Request persistent storage: `navigator.storage.persist()` - Safari may still ignore this 2. Educate users to add the app to home screen (PWA mode has longer retention) 3. Implement server-side backup with periodic sync 4. Show warning banner on Safari about potential data loss 5. Use Service Worker to maintain site engagement --- ### Issue: Slow Initial Load **Symptoms:** - App takes long time to start - IndexedDB queries are slow **Causes:** 1. Loading all data on startup 2. Missing indexes on queried fields 3. Too much data in single table **Solutions:** 1. Implement pagination, load visible data first 2. Add indexes for frequently queried fields 3. Partition data into multiple tables/stores 4. Use cursor-based queries instead of getAll() --- ## Testing Offline Scenarios ### Simulating Offline State ```typescript // test/offline-simulation.ts // Method 1: Service Worker intercept self.addEventListener("fetch", (event) => { if (globalThis.__SIMULATE_OFFLINE__) { event.respondWith(Promise.reject(new TypeError("Simulated offline"))); } }); // Method 2: Mock fetch for tests function createOfflineFetch(): typeof fetch { return async () => { throw new TypeError("Failed to fetch"); }; } // Method 3: Network Information API mock Object.defineProperty(navigator, "onLine", { get: () => false, configurable: true, }); ``` ### Test Scenarios Checklist ```markdown ## Offline Tests - [ ] Create item while offline → queued - [ ] Edit item while offline → local update + queued - [ ] Delete item while offline → soft delete + queued - [ ] View items while offline → from IndexedDB ## Sync Tests - [ ] Queue processes when online - [ ] Failed sync retries with backoff - [ ] Conflict detected when server data differs - [ ] Resolved conflicts update local ## Edge Cases - [ ] Rapid offline/online transitions - [ ] Multiple tabs editing same item - [ ] Storage quota exceeded - [ ] Large queue processing - [ ] Network timeout handling ``` --- ## Performance Considerations ### IndexedDB Optimization | Operation | Optimization | | ----------------- | ------------------------------------------ | | Bulk reads | Use `getAll()` with index, not `toArray()` | | Bulk writes | Use transactions, batch `put()` calls | | Large datasets | Implement pagination, virtual scrolling | | Complex queries | Add compound indexes | | Real-time updates | Use Dexie's `liveQuery` | ### Sync Optimization | Strategy | When to Use | | ------------------- | --------------------------- | | Delta sync | Default - only sync changes | | Batch uploads | Many small changes | | Compressed payloads | Large data volumes | | Background sync | Non-critical updates | | Priority queuing | Critical changes first | ### Memory Management ```typescript // Avoid holding large datasets in memory const BATCH_SIZE = 100; async function processLargeDataset(db: Database): Promise { let offset = 0; while (true) { const batch = await db.todos.offset(offset).limit(BATCH_SIZE).toArray(); if (batch.length === 0) break; await processBatch(batch); offset += BATCH_SIZE; // Allow GC between batches await new Promise((resolve) => setTimeout(resolve, 0)); } } ``` --- ## Security Considerations ### Sensitive Data in IndexedDB IndexedDB is not encrypted by default. For sensitive data: ```typescript // Encrypt before storing import { encrypt, decrypt } from "./crypto-utils"; async function saveSensitiveData( key: string, data: SensitiveData, ): Promise { const encrypted = await encrypt( JSON.stringify(data), await getEncryptionKey(), ); await db.sensitiveStore.put({ key, encrypted }); } async function getSensitiveData(key: string): Promise { const record = await db.sensitiveStore.get(key); if (!record) return null; const decrypted = await decrypt(record.encrypted, await getEncryptionKey()); return JSON.parse(decrypted); } ``` ### Authentication Token Storage ```typescript // Don't store tokens in localStorage (XSS vulnerable) // Use httpOnly cookies when possible // If must store locally, use IndexedDB with encryption // Clear sensitive data on logout async function logout(): Promise { await db.sensitiveStore.clear(); await db.syncQueue.clear(); // Retain non-sensitive cached data for offline use } ```