Skip to content

Optimistic Message Patterns

advanced18 min read

Why Waiting Feels Broken

You type a message in a chat app, hit send, and... nothing happens for 800ms while the request round-trips to the server. Then your message pops in. That delay is small in absolute terms, but it feels terrible. Your brain expected instant feedback — you typed the words, you pressed the button, the message should be there.

This is the core insight behind optimistic UI: show the result immediately, then reconcile with reality in the background. For regular CRUD apps, this is straightforward. For AI chat, it gets interesting.

AI chat has a unique timing profile. The user's message can appear instantly (you already know what it says — the user typed it). But the assistant's response takes seconds to even start, then streams in token by token. You're managing two fundamentally different update patterns in the same conversation thread.

Mental Model

Think of optimistic UI like a restaurant where the waiter writes your order on the table's whiteboard the moment you say it. You can see your order right away. Meanwhile, the waiter walks to the kitchen and submits it. If the kitchen says "we're out of that," the waiter comes back and crosses it out with a note. But 99% of the time, your order goes through — and you never felt like you were waiting.

The Optimistic Message Lifecycle

Every message in an AI chat goes through a clear state machine. Understanding this lifecycle is the difference between a chat that feels polished and one that feels janky.

Execution Trace
User presses Send
Input captured, submit handler fires
This is frame zero — everything after must feel instant
Optimistic append
Message added to local state with status: 'sending'
Client-generated temp ID, no server round-trip yet
Input cleared
Text input emptied, submit button disabled
Prevents double-send, signals the system received the action
API request fires
POST to /api/chat with message payload
Happens async — user already sees their message
Server confirms
Message status updated to 'sent', temp ID replaced
Server returns real ID — reconciliation happens silently
Thinking indicator
Assistant placeholder appears with loading animation
Tells the user the AI is processing, not frozen
Stream starts
Tokens arrive, assistant message builds incrementally
Replace thinking indicator with streaming content
Stream completes
Assistant message finalized, input re-enabled
User can send the next message

The critical part: steps 1 through 3 happen synchronously in the same event handler. The user sees their message and a cleared input before the network request even starts. That's what makes it feel instant.

What Happens When It Fails

The error path is where most implementations fall apart. Here's the wrong instinct and the right pattern:

Wrong: remove the failed message from the UI. The user just watched their message vanish. That's confusing and feels like the app ate their words.

Right: keep the message visible, mark it with an error state, and offer a retry button. The user's content is preserved. They understand what happened. They can fix it.

type MessageStatus = 'sending' | 'sent' | 'error' | 'streaming'

interface ChatMessage {
  id: string
  role: 'user' | 'assistant'
  content: string
  status: MessageStatus
  error?: string
}
Quiz
A user sends a message but the API returns a 500 error. What should the UI do?

Temporary IDs vs Server IDs

Here's a problem that sounds trivial but creates subtle bugs if you get it wrong. When the user sends a message, the server hasn't assigned an ID yet. But you need an ID immediately — React needs a stable key for the list, and you need to reference this message for status updates.

The solution: generate a temporary client-side ID, then swap it for the real server ID when the response arrives.

function generateTempId(): string {
  return `temp_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
}

Why not just use crypto.randomUUID()? You could. But prefixing with temp_ gives you a useful invariant: you can instantly tell whether a message has been confirmed by the server just by checking its ID.

function isConfirmed(message: ChatMessage): boolean {
  return !message.id.startsWith('temp_')
}

The Reconciliation Step

When the server responds with the real ID, you need to update the message in your state without causing a jarring re-render:

function reconcileMessage(
  messages: ChatMessage[],
  tempId: string,
  serverMessage: { id: string; createdAt: string }
): ChatMessage[] {
  return messages.map((msg) =>
    msg.id === tempId
      ? { ...msg, id: serverMessage.id, status: 'sent' as const }
      : msg
  )
}

The key insight: this is a map, not a filter-then-add. The message stays in the same position in the array. No flicker, no reorder, no layout shift.

Common Trap

If you use the message ID as a React key and swap the ID on reconciliation, React treats it as removing one element and adding another. This causes the component to unmount and remount — potentially losing scroll position, animation state, or focused elements inside the message. Solution: use a separate stable clientId for the React key that never changes, and keep the server ID as a data field.

interface ChatMessage {
  clientId: string
  serverId: string | null
  role: 'user' | 'assistant'
  content: string
  status: MessageStatus
}

Now clientId is your React key (never changes), and serverId arrives later from the server.

Quiz
You use message.id as the React key, and swap the ID from 'temp_abc123' to 'msg_789' when the server responds. What happens?

The Thinking Indicator

After the user's message appears and the API request is in flight, there's a gap before the assistant starts responding. This gap can be 1-10 seconds depending on the model, prompt length, and server load. Without visual feedback, the user wonders: did it work? Is it frozen? Should I refresh?

The thinking indicator bridges this gap. But there's a subtlety most people miss: you don't show it immediately.

function useThinkingDelay(isWaiting: boolean, delayMs = 400): boolean {
  const [showThinking, setShowThinking] = useState(false)

  useEffect(() => {
    if (!isWaiting) {
      setShowThinking(false)
      return
    }

    const timer = setTimeout(() => setShowThinking(true), delayMs)
    return () => clearTimeout(timer)
  }, [isWaiting, delayMs])

  return showThinking
}

Why the delay? If the assistant starts responding within 400ms, showing a thinking indicator for a split second creates visual noise — a flash of loading state that vanishes before the user processes it. The 400ms threshold lets fast responses skip the indicator entirely.

What makes a good thinking indicator

The best thinking indicators communicate three things: (1) the system received your message, (2) it's actively working, not stuck, and (3) a response is coming. Three animated dots are the convention because users have learned to read them as "someone is typing." But you can level it up: some AI chat UIs show "Thinking..." with a subtle shimmer, then transition to "Writing..." when streaming starts. This creates a two-phase mental model — processing, then producing — that feels more transparent about what's actually happening under the hood.

Error States Done Right

Not all errors are the same, and your UI shouldn't treat them that way. There are two fundamentally different failure modes:

Network errors — the request never reached the server. The user's message was never processed. Retry is safe and should be encouraged.

API errors — the request reached the server but was rejected. Maybe the model is overloaded (429), the input was flagged (400), or the server crashed (500). The retry strategy depends on the error type.

interface MessageError {
  type: 'network' | 'rate_limit' | 'content_filter' | 'server'
  message: string
  retryable: boolean
  retryAfterMs?: number
}

function categorizeError(error: unknown): MessageError {
  if (error instanceof TypeError && error.message === 'Failed to fetch') {
    return {
      type: 'network',
      message: 'No internet connection. Check your network and try again.',
      retryable: true,
    }
  }

  if (error instanceof Response) {
    if (error.status === 429) {
      const retryAfter = error.headers.get('Retry-After')
      return {
        type: 'rate_limit',
        message: 'Too many requests. Please wait a moment.',
        retryable: true,
        retryAfterMs: retryAfter ? parseInt(retryAfter) * 1000 : 30_000,
      }
    }

    if (error.status === 400) {
      return {
        type: 'content_filter',
        message: 'This message could not be processed.',
        retryable: false,
      }
    }
  }

  return {
    type: 'server',
    message: 'Something went wrong. Try again.',
    retryable: true,
  }
}

The retryable flag drives the UI: retryable errors show a retry button, non-retryable errors show an explanation.

Quiz
A chat API returns HTTP 429 with a Retry-After: 30 header. What should the optimistic UI do?

Input Management

Input management sounds boring until you get it wrong. Then users send duplicate messages, lose their typed content on errors, or can't tell whether their message was sent.

Three rules:

1. Clear on send, not on confirm. Clear the input the moment the user presses Send, not when the server confirms. If you wait for confirmation, the user sees their message sitting in the input for 200-800ms and wonders if the button worked.

2. Restore on error. If the send fails, put the text back in the input. The user shouldn't have to retype anything.

3. Disable during streaming. While the assistant is streaming a response, disable the submit button (but not the input — let them type ahead). When streaming completes, they can send immediately.

function useChatInput() {
  const [input, setInput] = useState('')
  const [savedInput, setSavedInput] = useState('')
  const [isStreaming, setIsStreaming] = useState(false)

  const handleSend = useCallback(() => {
    if (!input.trim() || isStreaming) return

    const message = input.trim()
    setSavedInput(message)
    setInput('')

    return message
  }, [input, isStreaming])

  const restoreInput = useCallback(() => {
    setInput(savedInput)
    setSavedInput('')
  }, [savedInput])

  return { input, setInput, handleSend, restoreInput, isStreaming, setIsStreaming }
}
What developers doWhat they should do
Clearing the input only after the server confirms the message
Waiting for server confirmation creates a 200-800ms delay where the user thinks the button didn't work. The input should respond to user action, not server state.
Clear immediately on send, restore on error
Removing failed messages from the conversation
Removing messages is disorienting. The user typed those words and watched them appear — vanishing them feels like data loss.
Keep them visible with error state and a retry button
Using the server-assigned ID as the React key
Swapping the key causes React to unmount and remount the component, losing local state and causing visual flicker.
Use a stable client-generated ID that never changes
Disabling the text input while streaming
Users think faster than AI responds. Blocking the input creates frustration. Let them compose their next message while waiting.
Disable only the submit button — let the user type ahead

Preventing Race Conditions

Race conditions in chat UIs are subtle. The user doesn't see them as "race conditions" — they see messages arriving out of order, duplicate responses, or the UI freezing. Here are the three most common ones and how to prevent them.

Double-Send Protection

The user mashes the Send button twice. Without protection, you fire two identical requests and get two assistant responses.

const isSubmitting = useRef(false)

async function handleSubmit(content: string) {
  if (isSubmitting.current) return
  isSubmitting.current = true

  try {
    await sendMessage(content)
  } finally {
    isSubmitting.current = false
  }
}

Why useRef instead of useState? State updates are asynchronous — between the click and the re-render, a second click can sneak through. A ref updates synchronously, closing the window completely.

Abort Previous Stream

User sends message A, then immediately sends message B before A's response finishes streaming. Now you have two streams writing to the UI simultaneously.

const abortControllerRef = useRef<AbortController | null>(null)

async function sendMessage(content: string) {
  abortControllerRef.current?.abort()

  const controller = new AbortController()
  abortControllerRef.current = controller

  try {
    const response = await fetch('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ message: content }),
      signal: controller.signal,
    })

    const reader = response.body?.getReader()
    if (!reader) return

    while (true) {
      const { done, value } = await reader.read()
      if (done || controller.signal.aborted) break
      appendToAssistantMessage(new TextDecoder().decode(value))
    }
  } catch (error) {
    if (error instanceof DOMException && error.name === 'AbortError') return
    handleSendError(error)
  }
}

The AbortController pattern ensures only one stream is active at a time. When a new message is sent, the previous stream is cancelled cleanly.

Message Ordering

If two messages are sent in quick succession (say, after aborting a stream), their server responses might arrive out of order. The solution: assign a monotonically increasing sequence number and discard responses that arrive out of order.

const sequenceRef = useRef(0)

async function sendMessage(content: string) {
  const seq = ++sequenceRef.current

  const response = await fetch('/api/chat', { /* ... */ })

  if (seq !== sequenceRef.current) return

  processResponse(response)
}

If seq no longer matches the current sequence when the response arrives, a newer message was sent in the meantime — discard the stale response.

Quiz
Why use useRef instead of useState to track whether a submission is in progress?

React 19 useOptimistic

React 19 introduced useOptimistic — a hook designed exactly for this pattern. It gives you an optimistic version of your state that automatically reverts when the async action completes.

import { useOptimistic } from 'react'

function ChatThread({ messages }: { messages: ChatMessage[] }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (currentMessages, newMessage: ChatMessage) => [
      ...currentMessages,
      newMessage,
    ]
  )

  async function sendMessage(formData: FormData) {
    const content = formData.get('message') as string
    const tempMessage: ChatMessage = {
      clientId: generateTempId(),
      serverId: null,
      role: 'user',
      content,
      status: 'sending',
    }

    addOptimisticMessage(tempMessage)

    const result = await submitMessage(content)

    if (!result.success) {
      // Optimistic state automatically reverts when the action
      // completes — you handle the error in the real state
    }
  }

  return (
    <div>
      {optimisticMessages.map((msg) => (
        <Message key={msg.clientId} message={msg} />
      ))}
      <form action={sendMessage}>
        <input name="message" />
        <button type="submit">Send</button>
      </form>
    </div>
  )
}

The useOptimistic hook is elegant but has a specific constraint: the optimistic state resets when the parent action (the form's action function or a useTransition callback) settles. This means it works naturally with Server Actions and form actions, but needs more manual orchestration for client-side streaming scenarios where the "action" doesn't complete until the entire stream finishes.

When useOptimistic doesn't fit AI chat perfectly

useOptimistic shines for simple optimistic mutations: like a message, toggle a bookmark, upvote a comment. The optimistic state shows immediately and either sticks (if the server confirms) or reverts (if it fails). But AI chat has a wrinkle: the user's message is optimistic, but the assistant's response is an additive stream that builds over seconds. The optimistic state for the user's message needs to persist and be joined by a progressively growing assistant message. This is more complex than the "show then confirm/revert" pattern that useOptimistic is designed for. In practice, most AI chat implementations use a dedicated message state array managed by useReducer or a library like Vercel AI SDK's useChat, which handles the multi-phase lifecycle natively.

How useChat Handles Optimistic Updates

The Vercel AI SDK's useChat hook is the most widely used solution for AI chat in React. Understanding how it handles optimistic updates internally teaches you the production-grade pattern.

When you call append() or submit a message through useChat, here's what happens:

import { useChat } from 'ai/react'

function Chat() {
  const { messages, input, handleInputChange, handleSubmit, isLoading, error } =
    useChat({
      api: '/api/chat',
      onError: (err) => {
        // Error handling — message stays in the array with error state
        console.error('Chat error:', err)
      },
    })

  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>
          <strong>{m.role}:</strong> {m.content}
        </div>
      ))}
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} />
        <button type="submit" disabled={isLoading}>
          Send
        </button>
      </form>
    </div>
  )
}

Under the hood, useChat does everything we've discussed:

  1. Optimistic append — the user message is added to the messages array immediately with a client-generated ID
  2. Input clearing — the input value resets to empty on submit
  3. Abort management — calling stop() aborts the current stream via AbortController
  4. Streaming assembly — as tokens arrive, the assistant message is built incrementally in the messages array
  5. Error preservation — if the request fails, the error object is populated but the user's message stays in the array
  6. Double-send prevention — the isLoading flag disables submission while a response is streaming

The key insight: useChat manages messages as a client-side array and treats the entire conversation as optimistic state. There's no separate "pending messages" queue — every message, whether confirmed or optimistic, lives in the same array with metadata indicating its state.

Quiz
In the Vercel AI SDK's useChat, what happens to the user's message if the API request fails?

Putting It All Together

Here's the complete mental model for optimistic message patterns in AI chat:

Key Rules
  1. 1Show the user's message immediately — never wait for server confirmation to display it
  2. 2Use a stable client-generated ID as the React key, keep server ID as a separate data field
  3. 3Clear input on send, restore on error — the input responds to user action, not server state
  4. 4Keep failed messages visible with error state and retry — never remove content the user created
  5. 5Disable submit during streaming, but let users type ahead in the input
  6. 6Use AbortController to cancel previous streams when a new message is sent
  7. 7Delay the thinking indicator by 300-500ms to avoid flashing it on fast responses
  8. 8Categorize errors (network vs rate limit vs server) and adapt the retry strategy accordingly