Interface Design

Making Updates Feel Instant

May 10, 2026

The multi-stage optimistic update pattern — how to make the UI respond in 5ms while the API takes 200ms, with rollback on failure and cache key precision.

When an administrator posts a shift, the card should appear instantly — not after a 200ms round trip to the server. The human perception threshold for “instant” is about 100ms. Anything slower feels like a delay. Anything under 50ms feels like the interface is reading your mind.

Optimistic updates bridge this gap. The idea is simple: update the UI immediately with a temporary version of the data, fire the API call in the background, then reconcile when the server responds. If the server fails, roll back. The user never sees the network latency.

The implementation is anything but simple.

The five-stage lifecycle

A shift posting goes through five stages in about 200ms, but the user only perceives the first one.

TIMET=0msUser clicks”Post Shift”Form closesT=5msOptimistic insert1. cancelQueries()2. Insert temp card3. Update counts +1Card appears!2s highlight glowT=10msAPI call firesPOST /v3/shiftsPromise.allSettled()~150-300ms network roundtripT~200msReconcile1. Replace temp ID with server ID2. Invalidate queriesRollback (on error)1. Remove temp card2. Restore counts -13. Show error toast✓ Success path✗ Error pathUSER PERCEPTION”Shift posted!”Card visible in ~5msUser has already moved onKey insightUI updates in 5msAPI confirms in ~200ms

Stage 1: User clicks “Post Shift” (T=0ms)

The form closes immediately. The user’s intent is captured.

Stage 2: Optimistic insert (T=5ms)

We generate a temporary shift card with an optimistic ID (optimistic-${Date.now()}-${index}), cancel any in-flight queries to prevent race conditions, and insert the card directly into the React Query cache. The shift count cache is also updated with a +1 delta.

await queryClient.cancelQueries({ queryKey: dayShiftsKey });

queryClient.setQueryData(dayShiftsKey, (old) => ({
  ...old,
  shifts: [...old.shifts, optimisticCard],
}));

The card appears on screen with a 2-second highlight glow. The user has already moved on.

Stage 3: API call fires (T=10ms)

The actual POST /v3/shifts request fires. For multiple shifts, we use Promise.allSettled() to handle partial failures — if three out of four shifts succeed, we keep the three and roll back the one.

Stage 4a: Reconcile on success (T~200ms)

When the server responds, we replace the optimistic ID with the real server ID in the cache. Then we invalidate and refetch to ensure full consistency.

Stage 4b: Rollback on failure (T~200ms)

If the API fails, we remove the optimistic card, restore the shift count with a -1 delta, and show an error toast.

Cache key precision

The trickiest part is knowing which cached queries to update. We use predicate-based query matching — a function that inspects query keys and returns true for the ones that need updating:

function createCountQueryMatcher(dateKey: string) {
  const target = new Date(dateKey).getTime();
  return (queryKey: QueryKey) => {
    const [url, facilityId, start, end] = queryKey;
    const rangeStart = new Date(start).getTime();
    const rangeEnd = new Date(end).getTime();
    return rangeStart <= target && target <= rangeEnd;
  };
}

A shift posted on March 4th will update the daily count, the weekly count, and the monthly count — all in one pass.

Deletion works differently

When deleting, we take a full snapshot of all relevant queries before modifying anything. If the API fails, every query is restored from this snapshot.

After a successful deletion, we invalidate queries without awaiting them. If we waited, a slow refetch could momentarily show the deleted shift again — a jarring flash. By invalidating in the background, the toast appears instantly and the cache self-heals asynchronously.

The perception gap

The entire lifecycle takes about 200ms. The user perceives only the first 5ms. The UI doesn’t need to wait for truth. It needs to predict truth, act on the prediction, and correct silently if wrong. For the 99.9% of cases where the prediction is correct, the user experiences zero latency.

← All chapters