686 lines
16 KiB
Markdown
686 lines
16 KiB
Markdown
# 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<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
|
|
```
|
|
|
|
```typescript
|
|
// ✅ 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
|
|
|
|
```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<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
|
|
|
|
```typescript
|
|
// ❌ 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
|
|
}
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// ✅ 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
|
|
|
|
```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<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);
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// ✅ 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
|
|
|
|
```typescript
|
|
// ❌ 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
|
|
```
|
|
|
|
```typescript
|
|
// ✅ 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
|
|
|
|
```typescript
|
|
// ❌ 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
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// ✅ 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
|
|
|
|
```typescript
|
|
// ❌ Bad - No quota handling
|
|
async function saveData(key: string, data: unknown): Promise<void> {
|
|
await db.cache.put(key, data); // May throw QuotaExceededError
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// ✅ 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
|
|
|
|
```typescript
|
|
// ❌ 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);
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// ✅ 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:**
|
|
|
|
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<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:
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```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<void> {
|
|
await db.sensitiveStore.clear();
|
|
await db.syncQueue.clear();
|
|
// Retain non-sensitive cached data for offline use
|
|
}
|
|
```
|