16 KiB
Offline-First Reference
Decision frameworks, anti-patterns, and troubleshooting for offline-first applications. See 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
// ❌ Bad - Hard delete loses data
async function deleteTodo(id: string): Promise<void> {
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
// ✅ Good - Soft delete with tombstone
async function deleteTodo(id: string): Promise<void> {
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
// ❌ 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
// ✅ Good - Verify with actual request
async function checkConnectivity(): Promise<boolean> {
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
// ❌ Bad - No retry on failure
async function syncItem(item: Todo): Promise<void> {
const response = await fetch("/api/todos", {
method: "POST",
body: JSON.stringify(item),
});
if (!response.ok) {
throw new Error("Sync failed"); // Lost forever
}
}
// ✅ Good - Queue with exponential backoff
async function syncItem(item: Todo): Promise<void> {
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
// ❌ 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
// ✅ 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
// ❌ Bad - Block UI while syncing
async function saveTodo(todo: Todo): Promise<void> {
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);
}
// ✅ Good - Local-first, non-blocking
async function saveTodo(todo: Todo): Promise<void> {
// 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
// ❌ Bad - No indication of sync state
function TodoItem({ todo }: { todo: Todo }) {
return (
<li>
<span>{todo.title}</span>
</li>
);
}
// Users don't know:
// - If their changes are saved
// - If changes will sync
// - If there's a conflict
// ✅ Good - Clear sync indicators
function TodoItem({ todo }: { todo: Todo }) {
return (
<li data-sync-status={todo._syncStatus}>
<span>{todo.title}</span>
<SyncIndicator status={todo._syncStatus} />
</li>
);
}
Why bad: Users lose trust when they can't see sync status.
Anti-Pattern 7: Unbounded Sync Queue
// ❌ Bad - No limits on queue
async function enqueue(operation: Operation): Promise<void> {
await db.syncQueue.add(operation);
// Queue can grow infinitely if offline for long time
}
// ✅ Good - Queue management with limits
const MAX_QUEUE_SIZE = 1000;
const QUEUE_PRUNE_THRESHOLD = 800;
async function enqueue(operation: Operation): Promise<void> {
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
// ❌ Bad - No quota handling
async function saveData(key: string, data: unknown): Promise<void> {
await db.cache.put(key, data); // May throw QuotaExceededError
}
// ✅ Good - Handle quota proactively
async function saveData(key: string, data: unknown): Promise<void> {
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
// ❌ Bad - Awaiting fetch inside transaction
async function updateWithServerData(id: string): Promise<void> {
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);
}
// ✅ Good - Fetch first, then transaction
async function updateWithServerData(id: string): Promise<void> {
// 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:
- Server rejects item (validation, auth)
- Conflict resolved in favor of server
- Hard delete on another device synced over
Solutions:
- Check server response for errors, log sync failures
- Implement user-visible conflict resolution
- Use soft deletes with tombstone retention
Issue: Duplicate Items After Sync
Symptoms:
- Same item appears multiple times after sync
- Duplicates have different IDs
Causes:
- Item created offline, synced, then local ID not updated to server ID
- Multiple devices create "same" item offline
Solutions:
- Server should return created ID, update local record
- Use client-generated UUIDs instead of server auto-increment
- Implement deduplication based on content hash
Issue: Sync Queue Never Empties
Symptoms:
- Pending count never goes to zero
- Same operations retry indefinitely
Causes:
- Server consistently rejects operations
- No max retry limit
- Operations queued faster than processed
Solutions:
- Log and surface server errors to user
- Implement max retry with dead-letter queue
- Batch operations, implement rate limiting
Issue: Optimistic Update Shows Then Reverts
Symptoms:
- UI updates immediately
- Then reverts after network call fails
Causes:
- Server validation fails
- Conflict with newer server data
- Authorization error
Solutions:
- Validate locally before optimistic update
- Show "pending" state, only confirm after sync
- Handle auth errors gracefully (re-authenticate)
Issue: IndexedDB QuotaExceededError
Symptoms:
- App crashes with QuotaExceededError
- Storage full error messages
Causes:
- Too much data cached
- Tombstones never cleaned up
- Sync queue growing unbounded
Solutions:
- Request persistent storage permission
- Implement tombstone cleanup (Pattern 12)
- Monitor storage with quota management (Pattern 17)
- 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:
- Request persistent storage:
navigator.storage.persist()- Safari may still ignore this - Educate users to add the app to home screen (PWA mode has longer retention)
- Implement server-side backup with periodic sync
- Show warning banner on Safari about potential data loss
- Use Service Worker to maintain site engagement
Issue: Slow Initial Load
Symptoms:
- App takes long time to start
- IndexedDB queries are slow
Causes:
- Loading all data on startup
- Missing indexes on queried fields
- Too much data in single table
Solutions:
- Implement pagination, load visible data first
- Add indexes for frequently queried fields
- Partition data into multiple tables/stores
- Use cursor-based queries instead of getAll()
Testing Offline Scenarios
Simulating Offline State
// 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
## 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
// Avoid holding large datasets in memory
const BATCH_SIZE = 100;
async function processLargeDataset(db: Database): Promise<void> {
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:
// Encrypt before storing
import { encrypt, decrypt } from "./crypto-utils";
async function saveSensitiveData(
key: string,
data: SensitiveData,
): Promise<void> {
const encrypted = await encrypt(
JSON.stringify(data),
await getEncryptionKey(),
);
await db.sensitiveStore.put({ key, encrypted });
}
async function getSensitiveData(key: string): Promise<SensitiveData | null> {
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
// 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<void> {
await db.sensitiveStore.clear();
await db.syncQueue.clear();
// Retain non-sensitive cached data for offline use
}