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:
- You push message A (optimistic).
- The server confirms message A and sends it back in a websocket.
- Now you have two copies of message A in your list. 🤣
Or even worse:
- You push message A.
- The server takes 2 seconds to respond.
- In the meantime, the server sends you message B from another user.
- 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:
- The Send: When a user hits send, we create a message object with a
temp_idandis_optimistic: true. - The API Call: We send that
temp_idto the server along with the message text. - The Response: The server processes the message and sends back the real database ID plus the
temp_idwe gave it. - The Swap: Our frontend sees the
temp_idin the response, finds the optimistic message in the list, and swaps thetemp_idfor the real ID and setsis_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