Go REST

Add optimistic updates without races

Render the new state before the server confirms; roll back on failure. Pattern works for create / update / delete.

Optimistic updates are what make a UI feel native. The user clicks a checkbox, the row updates instantly, the request goes out in the background. If the server agrees, nothing visible happens. If it rejects, the change rolls back. Done well, the latency disappears entirely. Done poorly, you confuse users about what is real.

The naive (pessimistic) version

Most code starts here: send the request, wait for the response, then update the UI. Easy to write, but every action has the round-trip latency baked in.

async function toggleStatus(id, current) {
  setBusy(true);
  const next = current === "active" ? "inactive" : "active";
  const res = await fetch(`/public/v2/users/${id}`, {
    method: "PATCH",
    headers,
    body: JSON.stringify({ status: next })
  });
  if (res.ok) {
    const updated = await res.json();
    setUsers(users => users.map(u => u.id === id ? updated : u));
  }
  setBusy(false);
}

Average API latency on a public service like Go REST is 100-300 ms. That much delay between a click and a UI change feels broken on every fast network. The fix is to update the UI first.

The optimistic version

Four steps:

  1. Apply optimistically. Update the local state as if the request already succeeded.
  2. Send the request. Fire the API call.
  3. Reconcile. When the response arrives, replace the optimistic copy with the server's truth (in case any field changed).
  4. Roll back on failure. If the server rejects, restore the previous state and tell the user.
async function toggleStatus(id, current) {
  const next = current === "active" ? "inactive" : "active";
  const previous = users.find(u => u.id === id);

  // 1. Apply optimistically
  setUsers(users => users.map(u =>
    u.id === id ? { ...u, status: next } : u
  ));

  try {
    // 2. Send to server
    const res = await fetch(`/public/v2/users/${id}`, {
      method: "PATCH",
      headers,
      body: JSON.stringify({ status: next })
    });
    if (!res.ok) throw new Error(`Got ${res.status}`);

    // 3. Reconcile with the server's truth
    const updated = await res.json();
    setUsers(users => users.map(u => u.id === id ? updated : u));
  } catch (err) {
    // 4. Roll back on failure
    setUsers(users => users.map(u => u.id === id ? previous : u));
    showToast("Could not save; reverted.");
  }
}

Notice we captureprevious before mutating; if the request fails, we put it back. The toast on rollback is essential: users need to know their action did not stick.

Optimistic create

The hard case. The new row needs an id before the server has issued one. Use a temporary id, swap it for the real one when the response arrives.

async function createUser(input) {
  const tempId = `temp-${Date.now()}`;
  const optimistic = { id: tempId, ...input, _pending: true };

  setUsers(prev => [optimistic, ...prev]);

  try {
    const res = await fetch("/public/v2/users", {
      method: "POST", headers, body: JSON.stringify(input)
    });
    if (!res.ok) throw new Error(`Got ${res.status}`);

    const real = await res.json();
    setUsers(prev => prev.map(u => u.id === tempId ? real : u));
  } catch (err) {
    setUsers(prev => prev.filter(u => u.id !== tempId));
    showToast("Could not create user.");
  }
}

The_pending flag is optional but useful: render the row at half opacity until the server confirms, so the user knows it is not fully saved.

Optimistic delete

Easy: remove from the list, restore on error.

async function deleteUser(id) {
  const previous = users;
  setUsers(prev => prev.filter(u => u.id !== id));

  try {
    const res = await fetch(`/public/v2/users/${id}`, {
      method: "DELETE", headers
    });
    if (!res.ok && res.status !== 404) throw new Error();
  } catch (err) {
    setUsers(previous); // restore everything
    showToast("Delete failed; restored.");
  }
}

Treat 404 as success: the row is gone, which is what we wanted.

With React Query

If you are using React Query / TanStack Query, optimistic updates are first-class. DefineonMutate,onError, andonSettled. The library handles the rollback wiring.

import { useMutation, useQueryClient } from "@tanstack/react-query";

function useToggleStatus() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ id, status }) => fetch(`/public/v2/users/${id}`, {
      method: "PATCH", headers, body: JSON.stringify({ status })
    }).then(r => r.json()),

    onMutate: async ({ id, status }) => {
      await qc.cancelQueries({ queryKey: ["users"] });
      const previous = qc.getQueryData(["users"]);
      qc.setQueryData(["users"], (rows) =>
        rows.map(r => r.id === id ? { ...r, status } : r)
      );
      return { previous };
    },

    onError: (err, vars, ctx) => {
      qc.setQueryData(["users"], ctx.previous);
    },

    onSettled: () => {
      qc.invalidateQueries({ queryKey: ["users"] });
    }
  });
}

The race condition

Optimistic updates can race. User clicks toggle twice fast, the second click sees the optimistic state of the first, and what happens? Three options:

For most UIs, option 1 (disable while busy) is plenty. Option 3 is right for collaborative editors and similar tools.

When NOT to be optimistic

Test the rollback path

The whole point of optimistic UI is that errors are rare and rollback is rarer. That makes it easy to ship a broken rollback. Use Go REST's?force_status=500 parameter to systematically trigger failures and confirm your rollback restores correctly:

fetch(`/public/v2/users/${id}?force_status=500`, {...})
More recipes

Keep building

All recipes Integration guides