Optimistic UI rollback on WebSocket nack #

You applied a mutation locally the instant the user clicked — a toggled like, a renamed file, a moved card — and pushed it to the server over a WebSocket. Most of the time the server acks and nobody notices the round trip. But occasionally the server returns a nack (validation failed, conflict, permission denied), or no ack arrives at all because the frame was dropped on a flaky connection. If your UI committed the change as if it were already durable, the user is now staring at state the server rejected. This page covers how to apply optimistically, track each mutation by a client-generated id, and roll back precisely the rejected mutation without clobbering concurrent edits or causing flicker.

Root cause #

An optimistic update is a bet that the server will accept your mutation. The bet has three outcomes: ack (you win, keep the change), nack (you lose, undo it), and silence (timeout — treat as a loss). The failure mode is treating the optimistic write as authoritative. Once you mutate shared state in place and forget what the value was before, you have no way to undo just that change — especially after other mutations or server broadcasts have landed on top of it.

Two properties make this hard over a WebSocket. First, there is no request/response pairing built into the protocol the way there is with HTTP; a send() returns nothing, so you must correlate the eventual ack frame back to the originating mutation yourself. That correlation is the client mutation id: a UUID minted on the client, attached to the outbound frame, and echoed back in the ack/nack. Second, frames are ordered per connection but mutations resolve out of order — the server may nack mutation #2 while #1 and #3 succeed. Rolling back must target #2 specifically, leaving the others intact. This is the same delivery-correlation problem discussed under message delivery guarantees, viewed from the client side.

The mechanism is a pending-mutations map keyed by client mutation id. Each entry stores the inverse patch (how to undo), the timeout handle, and the optimistic value. On ack you delete the entry and reconcile against the authoritative server value. On nack or timeout you apply the inverse patch to roll back. The base state the inverse patch reverts to is the server-confirmed state, not the current optimistic view, which is what keeps concurrent edits safe.

Optimistic apply then rollback timeline A mutation is applied locally and sent; on ack the UI keeps it, on nack or timeout the UI rolls back to the confirmed value. User edits apply + tag id Pending map store inverse Server ack or nack ack: drop entry keep + reconcile nack / timeout apply inverse

Resolution #

The store below holds two layers: confirmed (the last server-authoritative value for each entity) and a derived view (confirmed with all pending optimistic patches applied on top). Mutations are applied optimistically, tracked in pending, and resolved by client mutation id. Rolling back never mutates confirmed blindly — it recomputes the view from confirmed plus the surviving pending mutations, which is what keeps concurrent edits to other fields untouched.

import { v4 as uuid } from 'uuid';

const ACK_TIMEOUT_MS = 5_000; // treat silence past this as a nack

type EntityId = string;
type Entity = { id: EntityId; [field: string]: unknown };

interface PendingMutation {
clientMutationId: string;
entityId: EntityId;
patch: Partial<Entity>; // the optimistic change applied to the view
base: Entity; // confirmed snapshot at apply time (for ordering)
timer: ReturnType<typeof setTimeout>;
}

interface StoreState {
confirmed: Map<EntityId, Entity>; // server-authoritative truth
pending: Map<string, PendingMutation>; // keyed by clientMutationId
view: Map<EntityId, Entity>; // confirmed + pending, what the UI renders
}

class OptimisticStore {
private state: StoreState = {
confirmed: new Map(),
pending: new Map(),
view: new Map(),
};
private listeners = new Set<() => void>();

constructor(private ws: WebSocket) {
ws.addEventListener('message', (e) => this.onFrame(JSON.parse(e.data)));
}

subscribe(fn: () => void) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
getEntity(id: EntityId) { return this.state.view.get(id); }
private emit() { for (const fn of this.listeners) fn(); }

// Recompute the view for one entity: start from confirmed, then replay
// every pending patch for that entity in insertion order (Map preserves it).
private rebuildView(entityId: EntityId) {
const confirmed = this.state.confirmed.get(entityId);
let next = confirmed ? { ...confirmed } : undefined;
for (const m of this.state.pending.values()) {
if (m.entityId !== entityId) continue;
next = { ...(next ?? m.base), ...m.patch }; // later patches win — last-write-wins per field
}
if (next) this.state.view.set(entityId, next);
else this.state.view.delete(entityId);
}

// 1. Apply optimistically, tag with a client mutation id, send, arm timeout.
mutate(entityId: EntityId, patch: Partial<Entity>) {
const base = this.state.confirmed.get(entityId);
if (!base) return;
const clientMutationId = uuid();
const timer = setTimeout(
() => this.rollback(clientMutationId, 'timeout'), // no ack in time
ACK_TIMEOUT_MS,
);
this.state.pending.set(clientMutationId, { clientMutationId, entityId, patch, base, timer });
this.rebuildView(entityId); // UI updates instantly, before the server hears about it
this.emit();
this.ws.send(JSON.stringify({ type: 'mutate', clientMutationId, entityId, patch }));
}

// 2. Server frames: ack, nack, or an authoritative broadcast.
private onFrame(frame: { type: string; clientMutationId?: string; entity?: Entity }) {
if (frame.type === 'ack' && frame.clientMutationId) {
const m = this.state.pending.get(frame.clientMutationId);
if (!m) return; // already rolled back, or a duplicate ack
clearTimeout(m.timer);
// Reconcile: the server's entity is authoritative — it may have normalized
// or merged fields. Trust it over the optimistic patch.
if (frame.entity) this.state.confirmed.set(m.entityId, frame.entity);
this.state.pending.delete(frame.clientMutationId);
this.rebuildView(m.entityId);
this.emit();
} else if (frame.type === 'nack' && frame.clientMutationId) {
this.rollback(frame.clientMutationId, 'nack');
} else if (frame.type === 'sync' && frame.entity) {
// Unrelated broadcast (another client's confirmed edit). Update confirmed
// and replay our own still-pending patches on top so we don't lose them.
this.state.confirmed.set(frame.entity.id, frame.entity);
this.rebuildView(frame.entity.id);
this.emit();
}
}

// 3. Roll back exactly one mutation. Dropping it from `pending` and rebuilding
// reverts ONLY its patch — concurrent pending edits to the same or other
// entities are replayed untouched.
private rollback(clientMutationId: string, reason: 'nack' | 'timeout') {
const m = this.state.pending.get(clientMutationId);
if (!m) return;
clearTimeout(m.timer);
this.state.pending.delete(clientMutationId);
this.rebuildView(m.entityId);
this.emit();
console.warn(`[optimistic] rolled back ${clientMutationId} (${reason})`);
}
}

The key move is that rollback is subtractive, not an explicit inverse-apply against live state: you remove the mutation from pending and recompute the view from confirmed truth plus whatever else is still in flight. That sidesteps the classic bug where two rollbacks fight each other or a rollback steps on a concurrent edit. Because confirmed only ever advances from server frames, and the view is a pure function of confirmed + pending, the UI is always derivable and never stuck in a half-undone state. For wiring this kind of store into a real reducer pipeline, see syncing Redux state with WebSocket streams.

Operational checklist #

  • Every outbound mutation carries a unique clientMutationId

FAQ #

How do I avoid UI flicker when the server confirms a value that matches my optimistic one? #

Reconcile by replacing confirmed, but only emit/re-render when the recomputed view for that entity actually differs. Since the view is derived, an ack that produces an identical object can be diffed (shallow per-field) and skipped, so the common happy path causes zero visual change. Flicker comes from blindly rolling back to a stale base and then re-applying — the subtractive rebuild here avoids that.

What if a nack arrives after I’ve already applied newer edits to the same field? #

Each pending mutation stores its own patch, and rebuildView replays them in insertion order with last-write-wins per field. Removing the nacked mutation re-runs the survivors, so a newer edit to the same field stays on top. The rolled-back value only reappears if it was the most recent pending patch for that field.

Should the timeout fire a rollback or a retry? #

Default to rollback for user-initiated edits — silent retries can resurrect a mutation the user has mentally abandoned. If you need at-least-once semantics, retry with the same clientMutationId so the server can dedupe; this is the client half of the contract described in message delivery guarantees.

Does this need the server to send full entities on ack? #

It is the most robust option because the server may normalize, clamp, or merge fields you did not touch. If bandwidth matters, send only the changed fields plus a version, and merge them into confirmed rather than replacing — but never trust the raw optimistic patch as the committed truth.

Back to WebSocket State Sync and Optimistic Updates.