Back to blog
Article

Optimistic UI that rolls back without lying to the user

Optimistic UI that rolls back without lying to the user
S

StriveBit

4 min readWeb Applications

Optimistic UI that rolls back without lying to the user

A field technician in Bareilly taps "Mark complete" on a job card. The button flips to a green checkmark instantly. Seven seconds later, the request times out — the phone is on a 2G edge connection, the signal drops mid-request, and the server never received the update. The button stays green. The technician moves to the next job. The dispatcher sees a job marked complete that was never completed.

This is the failure mode that makes optimistic UI dangerous on the networks our clients' users actually have. The pattern is sound: update the UI immediately, fire the request in the background, and reconcile when the server responds. The problem is what happens between the optimistic update and the reconciliation, when the connection is bad enough that "eventually" could mean never.

We use a specific structure for this. The optimistic state carries its own metadata so the UI never confuses it with a confirmed state.

type JobStatus =
  | { kind: 'confirmed'; status: 'complete' | 'pending' }
  | { kind: 'optimistic'; status: 'complete'; rollbackTo: 'pending'; attemptId: string }
  | { kind: 'failed'; status: 'pending'; lastError: string };

function JobCard({ job }: { job: Job }) {
  const [state, setState] = useState<JobStatus>(
    { kind: 'confirmed', status: job.status }
  );

  const markComplete = async () => {
    const attemptId = crypto.randomUUID();
    setState({ kind: 'optimistic', status: 'complete', rollbackTo: 'pending', attemptId });

    try {
      await api.updateJob(job.id, { status: 'complete' });
      setState({ kind: 'confirmed', status: 'complete' });
    } catch (err) {
      setState({
        kind: 'failed',
        status: 'pending',
        lastError: err instanceof Error ? err.message : 'Network error',
      });
    }
  };

The key distinction is that `optimistic` and `confirmed` are different variants. The UI renders them differently — not dramatically, but enough that a careful user can tell. An optimistic checkmark gets a small spinner beside it. A confirmed one does not. This matters less for the technician, who taps and moves on, and more for the dispatcher sitting in the Noida office watching the board on a stable connection.

The rollback on failure is where most implementations go wrong. The common approach is to silently revert the UI to its previous state, as if the tap never happened. On a fast connection with a transient error, that is fine — the user taps again and never notices. On a bad connection where the same request fails three times in a row, silent rollback is gaslighting. The user taps, the button reverts, they tap again, it reverts again. They assume the app is broken.

Instead, the `failed` state keeps the rollback data but surfaces the failure. The button returns to pending, but a toast appears with the error and a retry action. The user knows the request failed and why. If they choose to ignore it, the UI is honest about the job's actual status.

There is a tradeoff here. Showing failure states adds complexity to what should be a simple interaction. For actions where a missed update is trivial — liking a post, dismissing a notification — silent rollback is the right call. We do not add the three-variant state machine to a thumbs-up button. For actions where the gap between what the UI shows and what the server believes has operational consequences, the complexity is justified. A job marked complete that the server does not know about is a scheduling problem. A payment marked sent that was not sent is a financial problem.

The timeout matters too. On mid-range Android devices with unstable connections, a 30-second default timeout is too long for an optimistic update. The user has moved on. We set optimistic updates to fail at 12 seconds — long enough for a slow 3G handshake, short enough that the user is still on the screen when the error toast appears. Confirmed reads use a longer timeout because they are not lying to the user in the meantime.

One detail we learned the hard way: the `attemptId` in the optimistic state exists to handle the case where the user taps retry, the first request finally succeeds after the timeout, and the retry also succeeds. Without an attempt identifier, you cannot tell which response corresponds to which tap. The server should be idempotent on the action, but the client still needs to know which promise to ignore.

The rule we follow: optimistic UI is a contract with the user that says "I will show you the result before I confirm it, and I will tell you if I cannot confirm it." Silent rollback breaks that contract. The UI should never be more confident than the data it has.

Back to all articles

Ready to build something great?

We help ambitious teams build software that lasts. If you're interested in working with us or want to discuss your project, let's connect.

Get in touch