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 →
technical-deep-dive · Aug 31, 2026
01

The hard part of a tender radar is not the matching

Most of the work in shipping BauGPT's Ausschreibungs-Radar had nothing to do with matching. Deciding whether a public tender fits a Bau company's profile was th...

7 MIN READ
technical-deep-dive · Aug 24, 2026
02

The hard part of a tender radar is not the matching

Most of the work in shipping BauGPT's tender radar had nothing to do with matching. Matching worked early and was never the risky part. Everything after that wa...

7 MIN READ
technical-deep-dive · Aug 20, 2026
03

What Bau Enterprises Actually Made Us Build

Most of what we shipped on BauGPT in the last weeks had nothing to do with the model. It was folders, permissions, single sign-on, and Word files that open corr...

7 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.