Skip to content

← Writing

engineering

Optimistic UI and Prefetching

· Jerwin Arnado · 8 min read ·

Part ten of the series. Every chapter so far optimized the system’s clock — real latency, real throughput. This one optimizes the user’s clock, which is the only one that pays you. And here’s the liberating truth: perceived performance and actual performance are different things, and perceived is the one that matters. A 200ms action that feels instant beats a 50ms action that shows a spinner. You can win on the clock that counts without touching the server — by not making the user wait for work they don’t need to watch.

Optimistic UI: assume success, reconcile on failure

The default (“pessimistic”) flow makes the user wait for the server to confirm before the screen reacts: tap “like” → spinner → server says OK → heart fills. That round trip is dead time the user spends staring at a loading state.

Optimistic UI flips it: update the screen immediately, assuming the action will succeed (because it almost always does), fire the request in the background, and only reconcile if it actually fails.

Pessimistic:  tap → [spinner...300ms...] → heart fills     feels sluggish
Optimistic:   tap → heart fills instantly → (request flies in background)
              → success: nothing more to do (the common case)
              → failure: revert the heart, show a quiet "couldn't save"

The bet is sound: the request succeeds ~99% of the time, so 99% of the time you correctly skipped the wait, and 1% of the time you gracefully undo. In Vue it’s a few lines:

async function toggleLike(post) {
  post.liked = !post.liked          // 1. update UI immediately — feels instant
  post.likeCount += post.liked ? 1 : -1
  try {
    await api.post(`/posts/${post.id}/like`, null, {
      headers: { 'Idempotency-Key': post.id + ':' + userId },  // safe to retry
    })
  } catch (e) {
    post.liked = !post.liked        // 3. reconcile: revert on failure
    post.likeCount += post.liked ? 1 : -1
    toast('Couldn\'t save your like — try again')
  }
}

Notice the idempotency key. Optimistic UI often retries on transient failure, and retrying is only safe if the write is idempotent — which is exactly why that chapter came first. Optimistic UI and idempotency are partners: one assumes success, the other makes the retry-after-uncertain-failure harmless.

Use optimistic UI where failure is rare and cheap to undo (likes, reordering, toggling, adding to a cart). Don’t use it where the user must know the true outcome before moving on — a payment, a booking confirmation. Assuming a charge succeeded and reconciling later is exactly the wrong bet; that’s a synchronous operation the user should wait for.

Prefetching: load what they’ll probably need next

The second half of perceived speed: do the work before the user asks, so it’s already there when they do. The user’s next action is often predictable, and idle time (while they’re reading, hovering, deciding) is free capacity to spend guessing.

  • Hover/intent prefetch: the user hovers a link for 100ms before clicking. Start fetching that page’s data on hover; by the time the click lands, it’s ready. This alone makes navigation feel instant.
  • Predictive prefetch: on a paginated list, prefetch page 2 while they read page 1. In a multi-step flow, prefetch the next step’s data. You know where they’re likely going — go there first.
  • Prefetch on viewport: as content scrolls near the fold, fetch it so it’s ready before it’s visible.
// Prefetch on intent — the data is warm before the click resolves
<router-link :to="`/clinics/${clinic.id}`" @mouseenter="prefetchClinic(clinic.id)">

function prefetchClinic(id) {
  queryClient.prefetchQuery(['clinic', id], () => api.clinic(id))  // cached; the click reads it instantly
}

The cost is real and worth naming: prefetching spends bandwidth and server work on things the user might not actually do. That’s the tradeoff — you trade some wasted requests for a UI that feels like it read the user’s mind. Spend it on likely next actions, not speculatively on everything.

Optimistic locking: the data-integrity sibling

“Optimistic” shows up in a second place, and it’s worth pairing here because it’s the same philosophy — assume no conflict, verify at commit — applied to data instead of UI. Optimistic concurrency control handles two users editing the same record: instead of locking the row while someone edits (pessimistic — safe but blocks everyone), you let both load it freely and detect the conflict at save time using a version number.

// Optimistic locking: the UPDATE only applies if the version hasn't moved since we read it
$affected = DB::table('appointments')
    ->where('id', $id)
    ->where('version', $expectedVersion)   // the row I loaded was version 7
    ->update([
        'notes'   => $newNotes,
        'version' => $expectedVersion + 1,
    ]);

if ($affected === 0) {
    // Someone else saved between my read and my write — version moved. Conflict.
    throw new StaleDataException('This record was changed by someone else — reload and retry.');
}

Same bet as optimistic UI: conflicts are rare, so don’t pay the cost of preventing them upfront (locks, blocking, contention) — just detect them when they happen and handle the rare case. For a clinic where two staff rarely edit the same appointment simultaneously, optimistic locking is free most of the time and correct always. (The row-version idea is the same one behind HTTP ETag/If-Match for APIs.)

Caveats and best practices

  • Optimistic UI only where failure is rare and reversible. Likes, toggles, reordering, cart tweaks: yes. Payments, bookings, anything the user must know succeeded: no — make them wait for the truth. The whole model rests on “success is the overwhelmingly common case.”
  • Always pair optimistic writes with idempotency. The reconcile-and-retry path will fire, and a non-idempotent retry is a double-action bug. This is why idempotency is a prerequisite, not a nice-to-have.
  • Make reconciliation honest and quiet. When the optimistic bet loses, revert cleanly and tell the user without drama (“couldn’t save — retry”). A silent revert that just makes their action disappear is worse than the spinner you removed.
  • Prefetch likely actions, not everything. Prefetching is spending real resources on a guess. Hover-intent and next-page are high-hit-rate guesses; prefetching every link on the page is just wasting your own capacity.
  • Optimistic locking beats pessimistic locking for low-contention edits. A version column and a conditional update give you correctness without holding locks. Reach for real locks only when contention is genuinely high.

Conclusion

Perceived > actual → a 200ms action that feels instant beats a 50ms spinner
Optimistic UI → update now, request in background, reconcile on the rare failure
             → only for rare+reversible actions; ALWAYS idempotent (retry-safe)
Prefetch → load the likely-next thing during idle time; spend guesses on high-hit actions
Optimistic locking → assume no conflict, detect via version at save — same bet, on data

The user’s clock is the only one that bills, and you can win it by design: stop making people wait for outcomes that are nearly always fine, and start work before they ask for it. Optimistic UI, prefetching, and optimistic locking are three faces of one idea — assume the common case, handle the rare one gracefully — and they lean on the idempotency and async foundations from earlier chapters. Next, the capstone: a worked design, end to end, putting the whole toolkit on one real system.