Eventual consistency UX patterns — how to hide it from the user
Microservices and async messaging mean write happens now but other systems see it soon. Users do not accept "soon" by default — but with the right UX they never notice.
The five patterns
1. Optimistic update
After the user clicks Save, immediately render the new state locally while the request flies off. Roll back on failure.
function save(draft: Comment) {
setComments(prev => [...prev, { ...draft, status: 'sending' }]);
api.post('/comments', draft)
.then(saved => setComments(prev => prev.map(c => c === draft ? saved : c)))
.catch(() => setComments(prev => prev.filter(c => c !== draft)));
}
2. Read-your-writes
After a write, route the next read for that user to the same partition / leader so they see their own update. Otherwise the user reloads and the comment vanishes.
3. Polling for completion
Long-running workflows: respond 202 Accepted + Location: /jobs/123. Client polls until status flips. Show a spinner with the right wording: "Your invoice is being prepared…".
4. Server-Sent Events / WebSocket push
Replace polling with a push channel. The server emits a state.changed event; UI updates without the user refreshing.
5. Stale data with a freshness indicator
Show the last-known value with a subtle "Updated 12 s ago" hint. Acceptable for dashboards; not for transactional flows.
Real example — order placement
| Step | UX |
|---|---|
| Click "Place order" | Show "Placing order…" toast, disable button |
| HTTP 200 from order-service | Toast "Order received — confirming payment" |
WebSocket payment.completed | Toast — "Order #123 confirmed", redirect |
WebSocket payment.failed | Toast — "Payment declined", restore cart |
Anti-patterns
- Refresh required — telling the user "refresh in a moment" reveals the broken abstraction.
- Ghost rows — the row exists in UI but not in DB; a refresh makes it disappear; user thinks the app lost their work.
- Different services, different latencies — the order page shows the new order but the dashboard count still says zero. Either show both from the same read model, or surface a clear "loading…" state per widget.
Backend support required
- Idempotency keys so optimistic-update retries do not double-charge.
- Stable IDs from the client so optimistic rows are reconcilable.
- Outbox + projection lag SLO — measure it; if p99 lag is over 2 s, your UX assumptions break.