Deferred Persistence
Gluonic separates optimistic updates (in memory) from confirmed data (in storage). This is the secret to clean optimistic UI with simple rollback.
The Problem
Traditional optimistic updates require complex rollback:
// Traditional approach
const handleSave = async () => {
// 1. Save to database optimistically
await db.update('posts', '123', { title: 'New Title' })
// 2. Update UI
setPosts(...)
// 3. Try server
try {
await fetch('/api/posts/123', { ... })
} catch (error) {
// 4. Rollback database β οΈ Complex!
await db.update('posts', '123', { title: oldTitle })
// 5. Rollback UI
setPosts(...)
// 6. What if rollback fails? π₯
}
}Issues:
- Database writes are slow
- Rollback is complex and error-prone
- What if rollback fails?
- Storage might be inconsistent
The Gluonic Solution
Three separate data layers with clear responsibilities:
βββββββββββββββββββββββββββββββββββββββββββ
β In-Memory Pool (Observable) β
β - Current state (optimistic + confirmed)β
β - UI reads from here β
β - Instant updates β
β - Easy to rollback (just replace) β
βββββββββββββββββββββββββββββββββββββββββββ
β Synced via MobX
βββββββββββββββββββββββββββββββββββββββββββ
β Transaction Queue (Persistent) β
β - Pending changes only β
β - Survives app restart β
β - Replayed on startup β
βββββββββββββββββββββββββββββββββββββββββββ
β Confirmed changes flow down
βββββββββββββββββββββββββββββββββββββββββββ
β Local Storage (Authoritative) β
β - Confirmed server data ONLY β
β - Never rolled back β
β - Source of truth on restart β
βββββββββββββββββββββββββββββββββββββββββββHow It Works
Write Path
// User saves a post
await store.save('post', '123', { title: 'New Title' })
// Step 1: Update pool (instant)
store.pool.upsert({
t: 'post',
id: '123',
p: { ...existingData, title: 'New Title' }
})
// UI updates immediately β
// Step 2: Queue transaction (persistent)
storage.enqueueTx({
id: 'tx-789',
type: 'post',
modelId: '123',
patch: { title: 'New Title' },
op: 'u',
prev: { title: 'Old Title' }
})
// Survives app restart β
// Step 3: Send to server (background)
POST /sync/v1/tx { ... }
// Step 4a: If success
// - Persist to storage
storage.putRow({ t: 'post', id: '123', p: { title: 'New Title' } })
// - Dequeue transaction
storage.dequeueTx('tx-789')
// Pool already has the data β
// Step 4b: If error
// - Rollback pool (easy!)
store.pool.upsert({
t: 'post',
id: '123',
p: { title: 'Old Title' } // Just replace
})
// - Dequeue transaction
storage.dequeueTx('tx-789')
// Storage unchanged βRead Path
// Component reads data
const post = useModel<Post>('post', '123')
console.log(post.title)
// Behind the scenes:
// 1. Proxy GET trap intercepts access
// 2. Reads from pool: store.pool.get('post', '123').p.title
// 3. MobX tracks this access
// 4. Returns current value (optimistic or confirmed)
// Pool might have: "New Title" (optimistic)
// Storage has: "Old Title" (confirmed)
// UI sees: "New Title" βWhy This Design?
1. Simple Rollback
Rollback is just a memory update:
// Rollback (easy)
store.pool.upsert({ t, id, p: previousData })
// vs Traditional rollback (hard)
await db.transaction(async (tx) => {
await tx.update('posts', id, previousData)
await tx.update('related_table', ...)
// What if this fails mid-way? π₯
})2. Clean State Separation
Each layer has one responsibility:
- Pool: Current UI state (fast, volatile)
- Queue: Pending operations (persistent, replay-able)
- Storage: Confirmed data (authoritative, never rolled back)
3. Queue Replay Works Correctly
On app restart:
await store.init()
// 1. Restore lastSyncId from storage
lastSyncId = await storage.getLastSyncId()
// 2. Load authoritative data via bootstrap
rows = await fetchBootstrap()
storage.putRows(rows) // Storage = confirmed
// 3. Replay queue to restore optimistic state
queue = await storage.listTx()
for (const tx of queue) {
// Re-apply optimistic updates to pool
store.pool.upsert({
t: tx.type,
id: tx.modelId,
p: { ...poolData, ...tx.patch }
})
}
// Pool = confirmed + optimistic β
// 4. User sees pending edits immediately βWhy this works: Storage has clean confirmed data. Queue correctly reconstructs optimistic layer on top.
Example Scenario
User Journey
// 1. User goes offline
// 2. User edits 3 posts
await store.save('post', '1', { title: 'Edit 1' })
await store.save('post', '2', { title: 'Edit 2' })
await store.save('post', '3', { title: 'Edit 3' })
// Pool state:
// - post:1 β title: "Edit 1" (optimistic)
// - post:2 β title: "Edit 2" (optimistic)
// - post:3 β title: "Edit 3" (optimistic)
// Storage state:
// - post:1 β title: "Original 1" (confirmed)
// - post:2 β title: "Original 2" (confirmed)
// - post:3 β title: "Original 3" (confirmed)
// Queue:
// - tx-1: update post:1
// - tx-2: update post:2
// - tx-3: update post:3
// 3. User closes app
// 4. User reopens app (still offline)
await store.init()
// Queue replays β Pool restored β
// User sees all 3 edits β
// 5. Network comes back
// Queue processes:
// - tx-1: Success β persisted, dequeued
// - tx-2: Conflict β rolled back, dequeued
// - tx-3: Success β persisted, dequeued
// Final state:
// Pool: Edit 1 β, Original 2 (rolled back), Edit 3 β
// Storage: Edit 1 β, Original 2 β, Edit 3 β
// Queue: (empty)Data Flow Diagram
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β USER ACTION β
β store.save('post', '123', patch) β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β POOL UPDATE (Instant - In Memory) β
β β Apply patch to observable pool β
β β MobX triggers component re-render β
β β UI shows new value immediately β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β QUEUE TRANSACTION (Persistent) β
β β Write to SQLite transaction table β
β β Survives app restart β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SERVER SYNC (Background) β
β β POST /tx with mutation β
β β Response (success or error) β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β
βββββββββ΄βββββββββ
βΌ βΌ
ββββββββββββ ββββββββββββββββ
β SUCCESS β β ERROR β
ββββββ¬ββββββ ββββββββ¬ββββββββ
β β
βΌ βΌ
ββββββββββββββββββ βββββββββββββββββββ
β PERSIST β β ROLLBACK β
β storage.putRow β β pool.upsert β
β storage.dequeueβ β storage.dequeue β
ββββββββββββββββββ βββββββββββββββββββImplementation Benefits
For Developers
- β No manual optimistic UI code
- β No complex rollback logic
- β No state management boilerplate
- β Automatic conflict handling
For Users
- β Instant UI responsiveness
- β Offline editing works
- β Pending changes survive restarts
- β Graceful error recovery
For System
- β Storage is always consistent
- β Queue replay is deterministic
- β No partial writes to handle
- β Clean separation of concerns
Comparison
| Approach | Optimistic Storage | Rollback Complexity | Queue Replay |
|---|---|---|---|
| Traditional | β Yes | β οΈ High (rollback DB) | β Broken |
| Gluonic | β No | β Low (replace memory) | β Perfect |
Next Steps
- Optimistic Updates - How updates work
- Reactive Models - How UI updates automatically
- Architecture - Complete data flow
Last updated on