Skip to Content
LearnCore ConceptsDeferred Persistence

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

ApproachOptimistic StorageRollback ComplexityQueue Replay
Traditionalβœ… Yes⚠️ High (rollback DB)❌ Broken
Gluonic❌ Noβœ… Low (replace memory)βœ… Perfect

Next Steps

Last updated on