Optimistic UI Updates
Tap the like button on a well-built mobile app and the count changes instantly. No spinner, no delay. The server confirmation arrives a few hundred milliseconds later, the count was right, and nothing visually changes. Now imagine the same interaction on a naively built app: you tap, a spinner appears, the count updates half a second later. Both apps produce the same result, but the first feels native and the second feels like using a web form. The difference is one engineering decision: apply the change locally before the server confirms it.
This guide sits in the Interactive Web cluster and connects the sync mechanics from realtime data synchronization to the user-facing interaction layer.
What you'll learn
The optimistic update pattern
The pattern has three steps that always run in the same order, regardless of what the operation is.
Snapshot, apply, confirm or revert
async function likePost(model) {
// 1. Snapshot current state for rollback
var previous = model.get('likes');
// 2. Apply optimistically
model.set({ likes: previous + 1, pending: true });
try {
// 3. Send to server in background
var result = await api.likePost(model.id);
// 4. Confirm: replace optimistic with authoritative value
model.set({ likes: result.likes, pending: false });
} catch (err) {
// 4. Reject: revert to snapshot
model.set({ likes: previous, pending: false });
showError('Could not save. Please try again.');
}
}
The snapshot is the single most important step: without a saved previous value, a rollback cannot restore anything. The optimistic apply happens before the await, so the UI updates synchronously on the same tick as the user interaction. The pending flag on the model is optional but useful; views can render a subtle indicator, a greyed-out button or a soft shimmer, that something is in flight without blocking interaction.
Tracking pending state
A boolean pending flag is enough for a single operation. When multiple operations can be in flight simultaneously, a counter or a map gives finer control.
A pending operation counter
// Use a counter so multiple concurrent operations stack correctly
function incrementPending(model) {
model.set('pendingCount', (model.get('pendingCount') || 0) + 1);
}
function decrementPending(model) {
var next = Math.max(0, (model.get('pendingCount') || 0) - 1);
model.set('pendingCount', next);
}
// In the view: show indicator when any operation is pending
var isPending = this.model.get('pendingCount') > 0;
A counter prevents the indicator from disappearing prematurely when two concurrent saves finish in different orders. The first to finish decrements to one; the second to finish decrements to zero and the indicator clears. A boolean would have cleared on the first completion regardless of the second still being in flight.
Rolling back on failure
A rollback that surprises the user is worse than a pessimistic UI. The visual design of the rollback matters as much as the technical correctness.
Animated revert with user feedback
function rollback(model, previous, errorMessage) {
// Revert the data
model.set(previous);
// Animate the revert so the user understands what happened
var el = document.querySelector('[data-id="' + model.id + '"]');
if (el) {
el.classList.add('is-reverting');
setTimeout(function () { el.classList.remove('is-reverting'); }, 600);
}
// Show a non-blocking error message
showToast(errorMessage, { type: 'error', duration: 4000 });
}
The animation communicates causality: the element visually moves back, telling the user the action was undone. A toast message explains why without blocking the interface. Avoid modal dialogs for transient save failures; they are disruptive for an error the user can simply retry. The key constraint is that the rollback must always restore to a state the server considers valid, never to a state that is worse than what was there before.
Reconciling with server responses
The server is the authority. When its response arrives, the client should use the server's value, not its own optimistic value, as the source of truth.
Accepting the authoritative result
// Always apply the server's value, not the optimistic one
model.set(serverResponse.data);
// Why: the server may have transformed the value
// User typed: "Hello world"
// Server stored: "Hello world" (trimmed, sanitised, timestamped)
// Optimistic value matched; no visual change needed
// But server may return { text: "Hello world", updatedAt: "..." }
// That updatedAt must be applied for future saves to work
The server may have transformed the input, trimmed whitespace, generated an ID, added a timestamp, or applied a business rule that modifies the value. Applying the server's response replaces the optimistic value with the correct one. When the values match, nothing visually changes. When they differ, the view updates to the canonical value, which is the right behaviour. This reconciliation step is what separates optimistic UI from just ignoring the server response.
Multiple in-flight updates
A user who types quickly or taps repeatedly will produce several in-flight operations. The pattern must handle them without corrupting state.
A pending operation queue
var pendingOps = [];
function submitUpdate(model, patch) {
var opId = Date.now() + Math.random();
var snapshot = model.toJSON();
// Apply optimistically
model.set(Object.assign({}, patch, { pendingCount: pendingOps.length + 1 }));
pendingOps.push({ opId, snapshot, patch });
api.save(model.id, patch).then(function (result) {
pendingOps = pendingOps.filter(function (op) { return op.opId !== opId; });
model.set(Object.assign({}, result, { pendingCount: pendingOps.length }));
}).catch(function () {
// Roll back to the snapshot taken before this operation
var idx = pendingOps.findIndex(function (op) { return op.opId === opId; });
if (idx > -1) {
model.set(Object.assign({}, pendingOps[idx].snapshot,
{ pendingCount: pendingOps.length - 1 }));
pendingOps.splice(idx, 1);
}
showError('Change not saved');
});
}
Each operation gets a unique ID and saves the snapshot taken at the moment it was created. On failure, the queue entry for that specific operation is found and its snapshot is restored. Later operations that were already optimistically applied on top may need to be reapplied, which is the core complexity. For most CRUD operations the queue stays short, typically zero or one entry, so the simple approach above is sufficient. Deeply nested concurrent edits to the same field are where operational transform and CRDTs become necessary.
Optimistic updates with Backbone
Backbone models have first-class support for optimistic saves through their save method and the wait option.
The wait option and manual optimism
// Pessimistic: wait for server before updating (wait: true)
model.save({ title: 'New title' }, { wait: true });
// Optimistic: update immediately, revert on error (wait: false, default)
var previous = model.previousAttributes();
model.save({ title: 'New title' }, {
error: function (m, response) {
m.set(previous);
showError('Save failed: ' + response.statusText);
}
});
// Manual: full control over snapshot + server response
model.set({ title: 'New title', pending: true });
api.save(model.id, { title: 'New title' })
.then(function (data) { model.set(Object.assign({}, data, { pending: false })); })
.catch(function () { model.set(Object.assign({}, previous, { pending: false })); });
By default, model.save applies the attributes immediately before the server responds, which is optimistic. Setting wait: true defers the local update until the server confirms, which is pessimistic. For the full pattern with pending indicators and animated rollbacks, the manual approach gives the cleanest control. Backbone's event system then propagates every set to listening views automatically, so the UI reflects each state, pending, confirmed, and reverted, without extra wiring.
Frequently Asked Questions
What is an optimistic UI update?
An optimistic UI update applies a change to the local interface immediately, before the server has confirmed it, on the assumption that the operation will succeed. If the server later rejects it, the change is rolled back and the user is shown feedback about the failure.
Why do optimistic updates feel faster?
Because the user sees the result of their action the instant they take it, rather than waiting for a network round trip. On a typical mobile connection a round trip can take several hundred milliseconds, which is perceptible as lag. Removing that wait makes the interface feel native rather than web-like.
What should a rollback look like?
A rollback should restore the previous state and show clear, non-alarming feedback. Undo the visual change, show a brief error message explaining why it failed, and offer to retry. An animation that reverses the original transition helps users understand what happened without feeling like a crash.
How do you handle multiple in-flight optimistic updates?
Track each pending operation with a unique ID in a queue. When a confirmation arrives, remove the matching entry. If multiple operations are pending, apply server confirmations in sequence order. If a rejection arrives, roll back only the rejected operation and reapply any later confirmed operations on top.
Read next: the Interactive Web hub, or continue to realtime data synchronization for the authoritative sync layer this pattern sits on top of.
Want instant-feeling Backbone model saves?
The Backbone guide walks through model save patterns including optimistic updates.
Explore the Backbone Guide →