Skip to Content
LearnCore ConceptsOptimistic Updates

Optimistic Updates

Optimistic updates make your UI feel instant by applying changes immediately, then confirming with the server in the background.

The Problem

Traditional apps wait for server confirmation:

// Traditional approach const handleSave = async () => { setLoading(true) // Show spinner 😔 try { await fetch('/api/posts/123', { method: 'PATCH', body: JSON.stringify({ title: 'New Title' }) }) // Finally update UI... 2 seconds later setPosts(posts.map(p => p.id === '123' ? { ...p, title: 'New Title' } : p )) setLoading(false) } catch (error) { setError('Save failed') setLoading(false) } }

Result: Sluggish UI, waiting for network, poor UX.

The Gluonic Solution

Apply changes instantly, confirm later:

// Gluonic approach const handleSave = async () => { // UI updates immediately - no loading state! 🎉 await store.save('post', '123', { title: 'New Title' }) // Done! Background sync handles the rest }

How It Works

Phase 1: Instant Application

await store.save('post', '123', { title: 'New Title' }) // Immediately (< 1ms): // 1. Update in-memory pool store.pool.upsert({ t: 'post', id: '123', p: { title: 'New Title' } }) // 2. MobX triggers component re-render // UI updates instantly ✓ // 3. Queue for server sync storage.enqueueTx({ id: 'tx-456', type: 'post', modelId: '123', patch: { title: 'New Title' }, op: 'u' })

Phase 2: Background Confirmation

// Background (next tick): // 1. Send to server POST /sync/v1/tx { ops: [{ t: 'post', id: '123', op: 'u', patch: { title: 'New Title' }, clientTxId: 'tx-456' }] } // 2. Server responds // Success ✓ or Error ❌

Phase 3: Persistence or Rollback

// If server confirms (success): // 1. Persist to local database storage.putRow({ t: 'post', id: '123', p: { title: 'New Title' } }) // 2. Dequeue transaction storage.dequeueTx('tx-456') // Pool already has the change ✓ // Storage now matches ✓
// If server rejects (error): // 1. Rollback from pool store.pool.upsert({ t: 'post', id: '123', p: previousData // Restore old value }) // 2. Dequeue transaction storage.dequeueTx('tx-456') // 3. MobX triggers re-render // UI reverts automatically ✓ // 4. Optionally notify user toast.error('Failed to save changes')

Visual Timeline

Time → 0ms 1ms 100ms 500ms │ │ │ │ User │ Click │ │ │ Action │ Save │ │ │ │ │ │ │ ▼ ▼ ▼ ▼ Pool █ Update │ │ │ Update █ Done ✓ │ │ │ │ │ │ │ UI │ █ Re-render│ │ Render │ █ Done ✓ │ │ │ │ │ │ Queue │ │ █ Enqueue │ Tx │ │ █ Done ✓ │ │ │ │ │ Server │ │ │ █ POST /tx Sync │ │ │ █ Response │ │ │ │ Storage │ │ │ │ Persist Write │ │ │ │ (if success) User sees change at 1ms ✓ Server confirms at 500ms ✓

Tracking Optimistic State

Gluonic tracks which fields have optimistic updates:

import { observer, useModel } from '@gluonic/client' const PostEditor = observer(({ postId }) => { const post = useModel<Post>('post', postId) // Check if a field is optimistic const titleIsOptimistic = post.isFieldOptimistic('title') return ( <div> <input value={post.title} onChange={e => store.save('post', postId, { title: e.target.value })} className={titleIsOptimistic ? 'saving' : ''} /> {titleIsOptimistic && <Spinner size="sm" />} </div> ) })

Conflict Resolution

When optimistic update conflicts with server:

// Client has optimistic update post.title = 'Client Version' // Server sends different value via delta sync frame: { t: 'post', id: '123', p: { title: 'Server Version' } } // Gluonic's behavior: // 1. Server version wins (last-write-wins) // 2. Optimistic update is discarded // 3. UI updates to server version // 4. User sees their change was overwritten // Optionally notify user: store.onConflict((type, id, fields) => { toast.warning(`Your changes to ${fields.join(', ')} were overwritten`) })

Queue Replay on Restart

Optimistic state survives app restarts:

// User makes changes offline await store.save('post', '123', { title: 'Offline Edit' }) await store.save('post', '456', { content: 'Another edit' }) // User closes app // (Changes queued but not confirmed) // User reopens app await store.init() // During initialization: // 1. Load authoritative data from local database // 2. Replay queued transactions to restore optimistic state // 3. User sees their pending edits immediately ✓ // Then background sync starts: // - Process queue // - Confirm with server // - Persist or rollback

Benefits

🎯 Instant Feedback

No waiting for network - changes appear immediately.

🔄 Automatic Rollback

Errors are handled gracefully without manual code.

💾 Survives Restarts

Pending changes persist across app sessions.

🎨 Clean Components

No loading states, no manual optimistic UI code:

// Just save - Gluonic handles everything const handleSave = async () => { await store.save('post', postId, { title, content }) } // No need for: // - setLoading(true/false) // - Optimistic local state updates // - Manual rollback on error // - Loading spinners

Tradeoffs

Advantages ✅

  • Instant UI updates
  • Better UX (feels native)
  • Simpler component code
  • Automatic conflict handling

Considerations ⚠️

  • Users might lose changes on conflict
  • Need to communicate pending state
  • Must handle eventual consistency
  • Server becomes source of truth

Best Practices

DO ✅

// Show optimistic state visually <Input value={post.title} className={post.isFieldOptimistic('title') ? 'opacity-70' : ''} /> // Notify on conflicts store.onConflict((type, id, fields) => { toast.warning('Your changes were overwritten by another user') }) // Show queue length const { queueLength } = useConnectionState() {queueLength > 0 && <Badge>{queueLength} pending</Badge>}

DON’T ❌

// Don't wait for confirmation await store.save('post', postId, { title }) await showSuccessMessage() // ❌ Might fail later! // Don't block on network setLoading(true) await store.save(...) // Already instant! setLoading(false) // Unnecessary // Don't manually track optimistic state const [optimisticTitle, setOptimisticTitle] = useState() // Gluonic does this for you ✓

Implementation Details

Deferred Persistence

Optimistic updates live in memory until confirmed:

In-Memory Pool: title: "New Title" (optimistic) Transaction Queue: { op: 'u', patch: { title: "New Title" } } Local Database: title: "Old Title" (authoritative) After Server Confirms: In-Memory Pool: title: "New Title" (confirmed) Transaction Queue: (empty - dequeued) Local Database: title: "New Title" (persisted)

Key Insight: Storage only contains confirmed data. This avoids complex rollback of persisted changes.

Optimistic Flags

Each model tracks which fields are optimistic:

// Internal metadata (WeakMap) optimisticFields.set(postInstance, new Set(['title'])) // Public API post.isFieldOptimistic('title') // true post.optimisticFields // ['title']

Next Steps

Last updated on