Optimistic Updates: Making Your App Feel Instant
Back to Blog

Optimistic Updates: Making Your App Feel Instant

March 21, 20263 min read25 views

Users hate waiting. Every millisecond of delay creates friction and makes your app feel sluggish. Optimistic updates show results immediately while the server catches up. When it confirms success, you're done. When it fails, you roll back gracefully.

The Psychology of Perceived Performance

Research shows users perceive applications as faster when they see immediate feedback, can continue working without breaks, and errors are rare. This last point is crucial: optimistic updates work because most operations succeed.

Implementing Optimistic Updates in React

React 19's useOptimistic makes this a first-class pattern:

'use client'

import { useOptimistic, useTransition } from 'react'

export function TodoList({ initialTodos }) {
  const [isPending, startTransition] = useTransition()
  
  const [optimisticTodos, updateOptimisticTodos] = useOptimistic(
    initialTodos,
    (currentTodos, action) => {
      switch (action.type) {
        case 'toggle':
          return currentTodos.map(todo =>
            todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
          )
        case 'delete':
          return currentTodos.filter(todo => todo.id !== action.id)
      }
    }
  )

  const handleToggle = async (id) => {
    startTransition(async () => {
      updateOptimisticTodos({ type: 'toggle', id })
      await toggleTodo(id)
    })
  }

  return (
    <ul>
      {optimisticTodos.map(todo => (
        <li key={todo.id} style={{ opacity: isPending ? 0.7 : 1 }}>
          <input type="checkbox" checked={todo.completed} onChange={() => handleToggle(todo.id)} />
          {todo.text}
        </li>
      ))}
    </ul>
  )
}

Handling Failures: The Rollback Dance

useOptimistic handles automatic rollback, but you need to inform users:

const handleSubmit = async (text) => {
  startTransition(async () => {
    addOptimisticComment({ text, author: 'You', pending: true })
    try {
      await addComment(postId, text)
    } catch (err) {
      toast.error('Failed to post comment', {
        action: { label: 'Retry', onClick: () => handleSubmit(text) }
      })
    }
  })
}

Race Conditions and Request Ordering

Multiple rapid requests can cause state confusion. Use request IDs or debouncing:

function LikeButton({ postId, initialLiked }) {
  const [liked, setLiked] = useState(initialLiked)
  const latestRequestRef = useRef(null)
  
  const handleClick = async () => {
    const requestId = crypto.randomUUID()
    latestRequestRef.current = requestId
    
    setLiked(!liked) // Optimistic
    const result = await toggleLike(postId)
    
    if (latestRequestRef.current === requestId) {
      setLiked(result.liked) // Server truth
    }
  }
}

When NOT to Use Optimistic Updates

Some actions should wait for server confirmation:

  • Financial transactions: Never show payment as successful before it succeeds
  • Destructive actions: Deletions should confirm first
  • Actions with dependencies: If B depends on A completing
  • High-failure-rate operations: More than 5% failure causes frustrating flickering

Key Takeaways

Show immediate feedback, always. Even just an opacity change.

Handle failures gracefully. Error messages and retry options.

Guard against race conditions. Request IDs or debouncing.

Know when to wait. Some actions must confirm before showing success.

Share this article