---
title: 'Optimistic Updates'
source: 'https://academia.sh/en/courses/frontend-architecture/optimistic-updates'
course: 'Application Architecture: Routing, State and Data'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

# Optimistic Updates

Reflecting a change onto the screen without waiting for the response — how snapshot-based rollback breaks under concurrent changes, the pending mutation layer, reconciliation with the server response, and where optimism does not apply.

The server state layer solved reading: the copy is identified by a key, used within a
freshness window, refreshed through invalidation. On the write side, however, what
appears on screen still depends on the speed of the network. The user deletes a
measurement in the North Slope Measurement Station application; the button switches to
waiting, and the row disappears only once the server responds.

Yet the result of the request is known in advance in most cases. If the delete will
succeed, it is already known that the row will disappear. An **optimistic update** is
reflecting the expected result onto the screen before the response, and reconciling
once the response arrives.

## What It Buys

The gain is not a shorter network duration — the request still takes the same time. It
is in the user's perception of waiting. Even a hundred-millisecond delay accumulates
across operations done back to back: a user deleting ten measurements one by one waits
for the screen to freeze on every delete.

Optimism also simplifies the interface. Every button does not have to have a waiting
state, every row does not have to have a "deleting" appearance; the screen shows the
result directly.

In return, a promise is made: what appears on screen is unconfirmed. When the promise
is broken — the request fails — the screen must roll back. That rollback is this
lesson's subject, because the obvious method fails silently in one situation.

## Two Methods of Rollback

The first method is direct: a **snapshot** of the screen is taken when the change
starts, and that snapshot is restored if the request fails.

The second method inserts a layer in between. Data from the server is kept separately
as the **base**; incomplete changes sit in a **pending mutation** list; what appears on
screen is recomputed every time by applying the pending changes on top of the base.
Failure means removing that change from the list.

The two give the same result when there is only one change. The difference appears
when two changes overlap.

```js
// optimistic-update.mjs — comparing snapshot rollback with the pending layer method
const BASE = ["T-01", "T-02", "T-03"];

const CHANGES = {
  A: { label: "delete T-02", apply: (l) => l.filter((x) => x !== "T-02") },
  B: { label: "add T-04", apply: (l) => [...l, "T-04"] },
};

// Method 1: a snapshot of the screen is taken when the change starts, restored on failure.
function snapshotMethod(events) {
  let screen = [...BASE];
  const snapshots = new Map();
  const rows = [];
  for (const [name, kind] of events) {
    if (kind === "start") { snapshots.set(name, [...screen]); screen = CHANGES[name].apply(screen); }
    else if (kind === "success") { snapshots.delete(name); }
    else { screen = snapshots.get(name); snapshots.delete(name); }
    rows.push([`${name} ${kind}`, [...screen]]);
  }
  return rows;
}

// Method 2: the server base is kept separate, pending changes are applied on top on every render.
function pendingLayerMethod(events) {
  let base = [...BASE];
  const pending = [];
  const render = () => pending.reduce((l, name) => CHANGES[name].apply(l), base);
  const rows = [];
  for (const [name, kind] of events) {
    if (kind === "start") pending.push(name);
    else {
      pending.splice(pending.indexOf(name), 1);
      if (kind === "success") base = CHANGES[name].apply(base);   // server confirmation is written into the base
    }
    rows.push([`${name} ${kind}`, render()]);
  }
  return rows;
}

function compare(title, events) {
  console.log(`-- ${title} --`);
  const a = snapshotMethod(events);
  const b = pendingLayerMethod(events);
  console.log("event".padEnd(14), "snapshot".padEnd(30), "pending layer");
  for (let i = 0; i < a.length; i++)
    console.log(a[i][0].padEnd(14), a[i][1].join(",").padEnd(30), b[i][1].join(","));
}

compare("single change, failure", [["A", "start"], ["A", "failure"]]);
compare("two changes, first fails", [
  ["A", "start"], ["B", "start"], ["B", "success"], ["A", "failure"]]);
compare("two changes, both succeed", [
  ["A", "start"], ["B", "start"], ["B", "success"], ["A", "success"]]);
```

```
-- single change, failure --
event          snapshot                       pending layer
A start        T-01,T-03                      T-01,T-03
A failure      T-01,T-02,T-03                 T-01,T-02,T-03
-- two changes, first fails --
event          snapshot                       pending layer
A start        T-01,T-03                      T-01,T-03
B start        T-01,T-03,T-04                 T-01,T-03,T-04
B success      T-01,T-03,T-04                 T-01,T-03,T-04
A failure      T-01,T-02,T-03                 T-01,T-02,T-03,T-04
-- two changes, both succeed --
event          snapshot                       pending layer
A start        T-01,T-03                      T-01,T-03
B start        T-01,T-03,T-04                 T-01,T-03,T-04
B success      T-01,T-03,T-04                 T-01,T-03,T-04
A success      T-01,T-03,T-04                 T-01,T-03,T-04
```

In the first scenario the two methods give the same result. The deleted row comes
back, and the screen returns to its pre-request state.

The second scenario's last row diverges. When the delete fails, the snapshot method
returns the screen to the state at the moment the delete started — and T-04, not yet
added then, disappears. The user sees the result of an operation they made no mistake
in vanish from the screen, with no explanation.

The pending layer method gives the correct result on the same row. The failed change
is removed from the list, and the base has already received the result of the
successful change; when the screen is recomputed, the deleted row comes back and the
added row stays in place.

The difference comes from the method itself: a snapshot stores the screen's result at
one moment, carrying the trace of other changes with it. The pending layer stores only
the change itself; rollback is removing a single change and recomputing.

## Reconciliation with the Server Response

When a successful response arrives, the pending mutation is removed from the list, but
the base must also be updated so the screen does not break. There are two paths.

If the server response returns the current record, the base is updated directly and no
additional request is made. If it does not, the invalidation from the Server State
Management lesson applies: the affected keys are made stale and refreshed on the next
read. The second path opens a short gap — the pending change is removed but fresh data
has not arrived — and the screen can show old data for a moment. The pending change
can therefore be left in the list until the refresh completes.

There are also cases where the optimistic result can diverge from the server's. When a
new record is added, its id is produced by the server; the client's temporary id is
replaced with the real one once the response arrives. Timestamps, sequence numbers, and
server-computed totals belong to the same group. The rule: the optimistic value is a
**guess**, the response is the source of truth, and the response wins when the two
conflict.

## Where Optimism Does Not Apply

Optimistic updates are not suitable for every operation. Three criteria rule them out.

**If the result cannot be predicted**, it does not apply. When the server runs a
validation, a quota check, or a conflict resolution, the client cannot know the
result, and the guess will often miss.

**If failure is routine**, it does not apply. Rollback is acceptable when it is rare;
when it is frequent, the screen looks like it is constantly correcting itself, and the
user cannot trust anything.

**If the operation cannot be undone**, it does not apply. Showing success on screen for
a payment, an external notification, or a permanent deletion presents something not
yet real as if it were. A waiting indicator is the correct behavior here.

The two approaches coexist in the same application: deleting a measurement and
changing a label are done optimistically; registering a station and changing a
permission are done by waiting for the response.

## The Visibility of Failure

Rollback cannot be done silently. A row that vanished from the screen coming back is,
to the user, an unexplained event; an interface that does not say the operation failed
leaves the user believing it succeeded.

A failure notification carries three things: which operation failed, why, and how to
retry. The notification must also reach the screen reader; the focus and announcement
rules established in the Document and Events topic apply here.

If the retry happens automatically, the backoff rules from the Asynchronous JavaScript
and the Runtime course apply, and the change stays in the pending list during the
retry. The user sees an error only once the final retry has also failed.

## Summary

- An optimistic update reflects the expected result onto the screen before the
  response; its gain is not in network duration but in the perception of waiting and in
  the interface's simplification.
- Snapshot-based rollback stores the screen's result at one moment, so it also erases
  the result of another change that arrived in between.
- The pending mutation layer keeps the server base separate and recomputes the screen
  every time; rollback is removing a single change from the list.
- A successful response is written into the base, or the relevant keys are
  invalidated; the optimistic value is a guess, and it loses to the server response
  when the two conflict.
- Optimism does not apply to operations whose result cannot be predicted, that fail
  often, or that cannot be undone; a waiting indicator is the correct behavior.
- Rollback is never done silently: which operation failed, why, and how it will be
  retried are reported to the user.

## Next Step

Where state belongs and how it is updated has been settled, but all of it lives in
memory. When the user closes the tab, the interface theme, the selected unit system,
and a half-filled measurement form disappear; when the page reloads, the application is
built up from scratch. The storage layers from the Browser and Web Platform course
provide the place to keep this, but which state is persisted, how a stored record is
read once the code has changed, and what two tabs writing to the same storage produce
are separate questions. The next lesson covers persisting state.
