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.
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.
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.
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.