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 rollbackBenefits
🎯 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 spinnersTradeoffs
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']Related Concepts
- Deferred Persistence - Why storage != pool
- Reactive Models - How UI updates automatically
- Delta Synchronization - Server confirmation protocol