babycat

A user presses Space on “Email alerts.” The switch immediately turns on. The request fails, the UI silently flips back, and a screen-reader user receives no explanation. Optimistic UI made the happy path faster by making the failure path ambiguous.

The control needs four truths, not one Boolean:

type ToggleState = {
  confirmed: boolean;
  desired: boolean;
  requestId: number | null;
  error: string | null;
};

Enter fullscreen mode Exit fullscreen mode

confirmed is server truth. desired is the user's latest intent. They can differ while a request is pending.

Use a native control

<label>
  <input id="alerts" type="checkbox" />
  Email alerts
</label>
<span id="status" role="status" aria-live="polite"></span>

Enter fullscreen mode Exit fullscreen mode

A checkbox already supports keyboard activation, focus, name, and checked state. CSS can make it look like a switch. Do not replace it with a clickable div.

Ignore stale responses

let state = { confirmed: false, desired: false, requestId: null, error: null };
let sequence = 0;

const input = document.querySelector("#alerts");
const status = document.querySelector("#status");

input.addEventListener("change", async () => {
  const requestId = ++sequence;
  state.desired = input.checked;
  state.requestId = requestId;
  state.error = null;
  render();

  try {
    const response = await updateSetting(state.desired);
    if (state.requestId !== requestId) return;
    state.confirmed = response.enabled;
    state.desired = response.enabled;
    state.requestId = null;
    render();
  } catch {
    if (state.requestId !== requestId) return;
    state.desired = state.confirmed;
    state.requestId = null;
    state.error = "Email alerts were not changed. Try again.";
    render();
  }
});

function render() {
  input.checked = state.desired;
  status.textContent = state.error ?? "";
}

Enter fullscreen mode Exit fullscreen mode

If request A turns the setting on and request B turns it off, A may finish last. The request ID prevents A from repainting obsolete intent. The server also needs ordering or conditional writes; client-side suppression cannot prevent an older server request from winning.

A stronger API includes a revision:

{ "enabled": true, "expectedRevision": 12 }

Enter fullscreen mode Exit fullscreen mode

It returns the new value and revision, or 409 when the client's base is stale. On conflict, fetch current truth and tell the user what happened.

Keep focus stable

Disabling during the request prevents rapid reversals, but can leave users waiting with no clear state. Keep focus on the existing input. Show a nearby visual pending indicator that is not repeatedly announced. Announce outcomes, not every network transition.

State Checked Operable Message
confirmed off off yes none
requesting on on yes visual “Saving”
confirmed on on yes none
rollback off yes polite failure message
conflict server value yes explain newer change

Do not use color alone for saving or error. Keep error text until another attempt or explicit dismissal, and associate it with the control.

QA matrix

Test with keyboard and supported screen-reader/browser combinations:

  1. Tab to the checkbox and press Space.
  2. Confirm focus remains while pending.
  3. Force HTTP 500; verify rollback and one announcement.
  4. Trigger on then off; deliver responses out of order.
  5. Simulate 409; verify server truth and conflict copy.
  6. Zoom to 200%; keep status visible.
  7. Enable reduced motion; remove nonessential animation.

Pointer testing misses a common regression: DOM replacement destroys focus. Update the existing input rather than remounting it with a new key.

Limits

Optimistic mutation fits reversible, low-consequence actions with a reliable reconciliation path. Do not optimistically present destructive, financial, permission, or privacy changes as complete. For those, preserve intent, show progress, and wait for authoritative confirmation.

The key accessibility question is not whether the toggle looks instant. It is whether a user can perceive and recover when that instant state turns out to be false.