Reconciling Optimistic Updates with SSE Events Permalink to this section

Part of State Management Integration, under Frontend Consumption & Client Patterns.

Optimistic updates make an interface feel instant: the change appears the moment the user acts, before the server confirms it. Server-Sent Events make it live: other users’ changes appear as they happen. Put the two together naively and they fight. The user’s own change comes back on the stream and is applied a second time, a concurrent change from someone else is overwritten by the optimistic value, or the UI flickers between old and new states. This guide builds the reconciliation layer that makes optimistic writes and streamed updates cooperate.

Symptom & Developer Intent Permalink to this section

  • A newly created item appears twice: once optimistically, once when its creation event arrives.
  • Toggling a checkbox flickers: on, off (a stale event), on (the echo).
  • When the server rejects a change, the optimistic value stays on screen.
  • Another user’s edit to the same record disappears when the local user’s optimistic write settles.
  • Counters drift because both the optimistic update and the event increment them.

The intent is that the user sees their change instantly, sees everyone else’s changes live, and the state always converges to the server’s version without duplicates or flicker.

Root Cause Analysis Permalink to this section

The robust model keeps two layers of state: confirmed state, built only from server data (snapshot plus stream events), and pending mutations, a list of the user’s optimistic changes not yet confirmed. The UI renders confirmed state with pending mutations applied on top.

Rendered state is confirmed state plus pending mutations Layers showing confirmed state built from the snapshot and stream events, a pending mutation layer holding the user's unconfirmed changes, and the rendered view combining both. Rendered state is confirmed state plus pending mutations Rendered view confirmed ⊕ pending Pending mutations optimistic, by mutation id Confirmed state snapshot + SSE events
Events only ever modify the confirmed layer. Mutations are removed from the pending layer when their confirmation arrives, never merged into confirmed state by hand.

Duplicates and flicker happen when optimistic changes are written directly into the same state that events update: the echo event then applies the same change again, and any older event that arrives in between briefly reverts it. Rejections leave stale values when there is no record of what to undo.

Step-by-Step Resolution Permalink to this section

Step 1 — Give every mutation a client id that the server echoes Permalink to this section

async function toggleDone(todoId, done) {
  const mutationId = crypto.randomUUID();
  pending.add({ mutationId, apply: (s) => patchTodo(s, todoId, { done }) });
  render();
  try {
    const res = await fetch(`/api/todos/${todoId}`, {
      method: 'PATCH', credentials: 'include',
      headers: { 'Content-Type': 'application/json', 'X-Mutation-Id': mutationId },
      body: JSON.stringify({ done }),
    });
    if (!res.ok) throw new Error(`rejected ${res.status}`);
  } catch (err) {
    pending.remove(mutationId);            // rejection: drop the optimistic layer, confirmed state stands
    render();
    notifyFailure(err);
  }
}

The server stores the mutation id with the change and includes it in the resulting stream event:

event: todo.updated
id: 9021
data: {"id":"t_17","done":true,"version":14,"mutationId":"3f0c…"}

Step 2 — Apply events to confirmed state, then clear matching mutations Permalink to this section

es.addEventListener('todo.updated', (e) => {
  const evt = JSON.parse(e.data);
  confirmed = upsertIfNewer(confirmed, evt);            // version check: never go backwards
  if (evt.mutationId) pending.remove(evt.mutationId);   // our own change is now confirmed
  render();
});

function view() {
  return pending.list().reduce((state, m) => m.apply(state), confirmed);
}

When the echo arrives, the confirmed layer now contains the change and the pending layer no longer does — the rendered value stays the same, so there is no flicker. A stale event older than the confirmed version is ignored by upsertIfNewer.

An optimistic toggle confirmed by the stream Sequence diagram of a user toggling a todo, the change rendered immediately from the pending layer, the PATCH accepted, and the stream echo moving the change into confirmed state and clearing the pending mutation. An optimistic toggle confirmed by the stream User Client state API Stream toggle t_17 add pending m1, render done PATCH X-Mutation-Id m1 200 todo.updated v14, mutationId m1 confirm, clear m1 (no visual change)
The rendered value never changes after the first click; only the layer it comes from does.

Step 3 — Handle creations with temporary ids Permalink to this section

For new items, the client does not know the server id. Create a temporary id, render it from the pending layer, and let the echo — carrying the mutation id and the real id — replace it:

function createTodo(title) {
  const mutationId = crypto.randomUUID();
  const tempId = `tmp_${mutationId}`;
  pending.add({ mutationId, apply: (s) => addTodo(s, { id: tempId, title, pending: true }) });
  render();
  postWithMutationId('/api/todos', { title }, mutationId).catch(() => { pending.remove(mutationId); render(); });
}
// On 'todo.created' with evt.mutationId, the pending entry (and its tempId row) disappears
// in the same render in which the confirmed row with the real id appears: no duplicate.

Keep React keys stable across the swap if the row should not remount (for example, key by mutationId while pending and map the real id to it).

Step 4 — Rebase pending mutations over concurrent changes Permalink to this section

Because pending mutations are applied on top of confirmed state at render time, a concurrent change from another user that arrives on the stream is automatically underneath the local user’s pending change. If the pending change later confirms, its echo reflects the server’s merge; if it is rejected because of the conflict (for example a 409 on a stale version), dropping it reveals the other user’s change. This is the same model used for collaborative editing in pairing SSE with POST for collaborative edits.

Step 5 — Expire mutations that never confirm Permalink to this section

If the stream is disconnected, an echo may never arrive. Clear a pending mutation when the HTTP response succeeds and a timeout passes without an echo, after refetching the affected entity, so the confirmed layer is current:

setTimeout(async () => {
  if (pending.has(mutationId)) {
    confirmed = upsertIfNewer(confirmed, await fetchTodo(todoId));
    pending.remove(mutationId);
    render();
  }
}, 10_000);

Validation & Monitoring Permalink to this section

Write reducer tests that apply events and mutations in every order: mutation then echo, echo arriving before the HTTP response, stale event between mutation and echo, rejection, and concurrent change. Each should end with rendered state equal to the server’s final state.

Event orderings the reconciler must handle Matrix of five orderings of optimistic mutations, HTTP responses and stream events, with the naive outcome and the layered outcome. Event orderings the reconciler must handle Ordering Naive single state Pending + confirmed Mutation, response, echo applied twice correct Echo before response applied twice correct Stale event before echo flicker ignored by version Rejected mutation stays wrong reverted Concurrent remote edit overwritten underneath, then resolved
The layered model produces the right answer in every ordering because events and mutations never write to the same place.

In production, count pending mutations that expire without an echo; a rising number means echoes are not carrying mutation ids, or the stream is disconnecting.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Why not just ignore stream events for my own changes?

The echo carries the server's version of the change, including fields the server computed and merges with concurrent edits. Ignoring it leaves the client with an unconfirmed guess.

Does TanStack Query's optimistic update pattern cover this?

It covers the request lifecycle. Combine it with the mutation-id echo so a streamed confirmation also settles the optimistic value; otherwise the stream and the mutation's onSettled refetch race.

What if the server cannot echo mutation ids?

Match by entity id and version instead: clear pending mutations for an entity once an event with a version newer than the one the mutation was based on arrives.

How long should pending mutations live?

Until their echo or a failure — with a safety timeout of several seconds plus a refetch, so a lost echo never leaves the interface showing an unconfirmed state indefinitely.