JONAS STAMM
— FIELD NOTES
ai-engineeringApril 19, 2026· 4 min read

The Nightmare of Optimistic UI in Chat Apps (And How We Fixed It at BauGPT)

The Nightmare of Optimistic UI in Chat Apps (And How We Fixed It at BauGPT) Users are spoiled. 🤣 If you click "send" in a chat app and see a loading spinner fo...

JS
Jonas Stamm
Founder, BauGPT

The Nightmare of Optimistic UI in Chat Apps (And How We Fixed It at BauGPT)

Users are spoiled. 🤣

If you click "send" in a chat app and see a loading spinner for even a second, it feels broken. We’ve all been trained by WhatsApp and iMessage to expect instant feedback. The message should pop up immediately, even if the server is still grinding away in the background.

This is called Optimistic UI. It sounds great in theory, but in practice, it’s a total nightmare to get right. 🫢

We just spent a few days fixing this for BauGPT Mobile (commit f1b2456 for the curious). Here’s the mess we ran into and how we actually solved it.

The Problem: The "Double Message" Ghost 👻

The simplest way to do Optimistic UI is to just push the message into your local state immediately, then fire off the API call.

But then things get weird:

  1. You push message A (optimistic).
  2. The server confirms message A and sends it back in a websocket.
  3. Now you have two copies of message A in your list. 🤣

Or even worse:

  1. You push message A.
  2. The server takes 2 seconds to respond.
  3. In the meantime, the server sends you message B from another user.
  4. Suddenly your local list has A, then B, then the "real" A. Everything jumps around. It feels like the app is possessed.

Our First Approach (The Failure)

We tried to deduplicate messages using their database IDs. If a message came from the server and we already had that ID, we’d ignore it.

Problem: The optimistic message doesn't have a database ID yet. It only has a "text" and a "timestamp." Matching on those is a recipe for disaster — what if a user sends the same word twice?

The Fix: Temporary IDs and State Reconciliation

The solution isn't to guess. It's to be explicit. ✌️

We moved all chat state into a unified hook. Instead of just "pushing to a list," we give every optimistic message a temp_id (a simple UUID).

Here’s the logic we implemented:

  1. The Send: When a user hits send, we create a message object with a temp_id and is_optimistic: true.
  2. The API Call: We send that temp_id to the server along with the message text.
  3. The Response: The server processes the message and sends back the real database ID plus the temp_id we gave it.
  4. The Swap: Our frontend sees the temp_id in the response, finds the optimistic message in the list, and swaps the temp_id for the real ID and sets is_optimistic: false.

Here’s a simplified version of the React hook we use:

const useChat = (conversationId: string) => {
  const [messages, setMessages] = useState<Message[]>([]);

  const sendMessage = async (text: string) => {
    const tempId = uuid();
    const optimisticMessage = { id: tempId, text, isOptimistic: true };

    // Push immediately
    setMessages(prev => [...prev, optimisticMessage]);

    try {
      const response = await api.post('/messages', { text, tempId, conversationId });
      
      // Swap temp for real
      setMessages(prev => prev.map(m => 
        m.id === tempId ? { ...response.data, isOptimistic: false } : m
      ));
    } catch (e) {
      // Handle error (remove optimistic message or show retry)
      setMessages(prev => prev.filter(m => m.id !== tempId));
      console.error("Failed to send", e);
    }
  };

  return { messages, sendMessage };
};

Why This Works

By including the tempId in the server round-trip, the client doesn't have to guess which message the server is talking about. Even if the websocket is faster than the API response, we can handle it.

If the websocket sends us a message with a tempId we recognize, we know it's our own message being confirmed. If it doesn't have a tempId, it's a new message from someone else.

The Results

Since we pushed this fix, the chat in BauGPT feels 10x more solid. No jumping. No duplicates. No "ghost" messages that vanish and reappear. ✌️

It's one of those things that users will never notice when it's working perfectly, but they’ll scream if it’s 1% off. 🤣

The Takeaway

Optimistic UI is basically "fake it 'til you make it" for software. But you need a solid reconciliation strategy or the "faking it" part will bite you.

Don't just append to lists. Use temporary IDs, track the "optimistic" state explicitly, and make sure your server plays along.

If you're building a chat app (or anything real-time), do this early. Changing state logic later is a lot more painful. 🙃

LG Jonas

Keep reading

All writing →
build-in-public · Jun 1, 2026
$ we run ai agents inside baugpt.
# build-in-public
read-time: 7min

We run AI agents inside BauGPT. Here's what it taught us about building them.

We build AI for the construction industry. We also run AI agents inside our own company to handle scheduling, ticket routing, code review, and content ops. That...

7 MIN READ
product · May 21, 2026
02

Our enterprise onboarding takes 90 minutes. The procurement took 11 weeks.

A construction company with a four-billion-euro annual turnover signed up for BauGPT last quarter. Their procurement process took eleven weeks. The actual onboa...

4 MIN READ
product · May 21, 2026
03

We process 40,000 WhatsApp messages a week. Here's why we built there.

BauGPT processes 40,000 WhatsApp messages a week. About 40% of them are voice notes. I mention this not to flex on a number. I mention it because it explains ev...

4 MIN READ
— THE NEWSLETTER

One note a week.
No fluff, just what works.

AI engineering, growth hacks, and messy lessons from shipping BauGPT. Unsubscribe anytime. I'll even miss you.

FIELD NOTES · NEXT ISSUE DROPS MONDAY
↳ No spam. One note weekly.