DEV Community

Cover image for Build an Optimistic Update Queue, Then Prove the Rollback Contract
Karuha
Karuha

Posted on Originally published at aceround.app

Build an Optimistic Update Queue, Then Prove the Rollback Contract

Optimistic UI is only honest when a failed request cannot repaint the screen with an older state. The small contract in this post is: render the newest intent immediately, serialize writes for one resource, and recompute the display after every result. It is framework-agnostic, testable in Node.js, and easy to explain in a frontend interview.

Optimistic update queue: render newest local value, persist one write, then commit or discard and recompute

Why a single rollback is not enough

Imagine a settings toggle. A user changes draft to review, then immediately to approved. If the first network request fails after the second choice is visible, the common “save previous value and restore it in catch” recipe restores draft. The UI now contradicts the user's latest action.

This is not a rare edge case. It appears in inline edits, reaction buttons, drag-and-drop ordering, and any form with an eager save. The issue is not that optimistic rendering is wrong. The issue is that a response is allowed to mutate state without proving that it still represents the current intent.

There are two valid broad strategies:

Strategy Best when Cost
Last-write-wins with a server version The API exposes versions or timestamps You must reconcile server conflicts
Per-resource write queue Updates have an order and must all reach the server Later changes wait behind an earlier request

This post uses a queue because it gives a compact, executable interview exercise. It keeps the server order and lets the UI still show the last local choice immediately.

What state does the controller need?

Only four pieces of state are necessary:

  1. confirmed: the last value known to have persisted.
  2. visible: what the UI currently renders.
  3. queue: local intentions that have not finished.
  4. running: whether the head of that queue is on the wire.

The important invariant is simple: the visible value is the newest queued value, or the confirmed value when the queue is empty.

That means a failed older update disappears from the queue, but it cannot overwrite a newer queued update. Here is the complete controller:

function deferred() {
  let resolve;
  let reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}

function createOptimisticWriter(initialValue, save) {
  let confirmed = initialValue;
  let visible = initialValue;
  let running = false;
  const queue = [];

  const repaint = () => {
    visible = queue.at(-1)?.value ?? confirmed;
  };

  async function drain() {
    if (running || queue.length === 0) return;
    running = true;
    const entry = queue[0];

    try {
      confirmed = await save(entry.value);
      entry.resolve({ status: "committed", value: confirmed });
    } catch (error) {
      entry.resolve({ status: "rolled-back", error });
    } finally {
      queue.shift();
      repaint();
      running = false;
      void drain();
    }
  }

  return {
    get value() {
      return visible;
    },
    submit(value) {
      const completion = deferred();
      queue.push({ value, ...completion });
      repaint();
      void drain();
      return completion.promise;
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

A React component does not need to know this implementation. It can call writer.submit(nextValue), render writer.value, and subscribe or set state after each transition. The separation is useful in an interview because it makes the business rule testable without a browser, JSX, or a mock server.

Which failure should the test reproduce?

Do not stop at “the request rejects.” Reproduce the ordering bug:

  1. The first update starts and remains pending.
  2. The user makes a second update, which becomes visible immediately.
  3. The first update fails.
  4. The second update starts, then succeeds.
  5. The screen must still show the second value.

Here is a dependency-free Node.js test. It controls completion order rather than relying on timers:

import assert from "node:assert/strict";

const requests = [];
const writer = createOptimisticWriter("draft", (value) => {
  const request = deferred();
  requests.push({ value, ...request });
  return request.promise;
});

const first = writer.submit("review");
const second = writer.submit("approved");

assert.equal(writer.value, "approved");
assert.equal(requests.length, 1);
assert.equal(requests[0].value, "review");

requests[0].reject(new Error("offline"));
assert.equal((await first).status, "rolled-back");
await new Promise((resolve) => queueMicrotask(resolve));

assert.equal(writer.value, "approved");
assert.equal(requests.length, 2);
assert.equal(requests[1].value, "approved");

requests[1].resolve("approved");
assert.deepEqual(await second, {
  status: "committed",
  value: "approved",
});
assert.equal(writer.value, "approved");
Enter fullscreen mode Exit fullscreen mode

That test establishes three useful facts: the first request did not erase the second intent, only one write was active at a time, and the server's final acknowledgement agrees with the display.

Where does this approach stop being enough?

A queue is deliberately conservative. It is appropriate for a single document title, a profile setting, or a preference where writes are small and ordered. It is not a universal answer.

For a text editor, queuing every keystroke may create unnecessary traffic and latency. Debounce input first, then queue the semantic save. For a multi-user document, local ordering cannot resolve another person's concurrent change; use server versions, operational transforms, or a conflict-aware protocol. For an operation such as “add item to cart,” a server-side idempotency key is often more important than a client queue because retries can cross page reloads.

Also decide what happens after a terminal failure. This example moves on to the next intent because the later value is still meaningful. In a payment workflow, that would be dangerous: block the queue, show the exact failure, and make the retry explicit. A good implementation follows the domain's consistency requirement, not a generic UI trick.

How I would explain this in an interview

A clear two-minute answer sounds like this:

“I would update the screen optimistically, but I would not let every response write state blindly. For one resource, I keep a confirmed value and a queue of local intents. The UI renders the newest queued value. I serialize the writes, and after either success or failure I remove only the completed entry and derive the display again. My test rejects an older request after a newer intent is visible, so I can prove the rollback does not clobber the newer choice. If the API supports versions, I would consider a versioned last-write-wins policy instead.”

That answer names the invariant, the failure mode, the test, and the trade-off. It is stronger than saying “I would use optimistic updates” and hoping the interviewer does not ask what happens when requests complete out of order.

For a useful rehearsal, explain this design once without looking at the code, then ask a practice partner to change one constraint: “What if another user edits the same record?” aceround.app — an AI interview assistant can be used to generate that follow-up pressure during preparation.

FAQ

Why not send both requests concurrently and ignore stale responses?

That can work when the server exposes a monotonic version or an authoritative final representation. Without that contract, a late response may describe server state that has already been overwritten or may hide a write conflict. The queue makes ordering explicit on the client; it does not replace server-side concurrency control.

Should the UI show an error after the first update fails?

Usually yes, but attach the message to the failed operation rather than replacing the entire value with an old snapshot. The user may already have supplied a newer correction. A transient toast, retry affordance, or field-level status is often enough.

Does React already solve this?

React provides primitives for optimistic rendering, but application code still decides what a mutation means, which requests may overlap, and how a server rejection changes local state. The concurrency policy remains your responsibility.

Sources

Disclosure: AI assistance was used to edit this article. The algorithm and the executable assertions were reviewed and run before publication.

Top comments (0)