Today I worked on one of those bugs that sounds suspiciously simple when someone describes it.
"When a new message comes in, the message screen updates. But when I open the chat, the message is not there. If I go back, refresh, and open it again, then it shows."
That sentence has all the ingredients of a quiet afternoon turning into a long stare at state management.
The app has a fairly normal chat flow. There is a messages screen with conversation previews, unread counts, recent chats, and the latest message text. Then there is a chat screen that shows the actual thread. Messages arrive through a socket. Push or foreground notifications can also show a snackbar. The backend stores read status, and the app marks messages as read once the user opens the chat.
Nothing exotic. Which, of course, is exactly why it was interesting.
The preview was telling the truth
The first clue was that the message list did update.
When a message arrived, the preview changed. The unread count changed. The notification snackbar appeared. So this was not a socket connection issue, not a missing event, and not a routing issue.
The list knew about the new message.
But the chat screen did not.
That narrowed the shape of the bug quite a bit. Somewhere between "socket event reached the messages screen" and "chat screen opened", the data was getting thinner.
The messages screen had a ChatPreview model. It carried the things you expect:
class ChatPreview {
final String latestMessage;
final int unreadCount;
final String userId;
final String? conversationId;
final List<ChatMessage> initialMessages;
// ...
}
That last field was the important one: initialMessages.
The chat screen was not always fetching a fresh thread immediately when opened. It was initialized from the preview object:
_messages = widget.initialMessages ?? [];
So the list screen was not just a list screen. It was also acting as a lightweight cache for the first render of the chat screen.
That is a reasonable design. It makes the chat open quickly. It avoids a blank screen while waiting for the network. It also means the cache has to be correct.
And mine was not.
The tiny split-brain
The socket handler on the messages screen was doing something like this:
existing.copyWith(
latestMessage: latestMessage,
time: timestamp,
unreadCount: existing.unreadCount + 1,
);
That made the preview look right.
But it did not add the new socket message to initialMessages.
So the user saw the new message in the list, tapped the conversation, and the chat screen opened with the old cached message list. The newest message was effectively living in the preview text, but not in the actual message collection.
That explains the weird part where the message appeared only after a manual refresh. The refresh pulled the full conversation again from the backend, rebuilt initialMessages, and finally the chat screen had the missing message.
It also explained the unread count behaving oddly. Opening the chat locally cleared the unread count in the list UI, but the chat screen could not mark that new message as read because the message was not in _messages. After refresh, the backend still considered it unread. That was not the backend being stubborn. It was the UI never giving the read-marking code the message id it needed.
This is the kind of bug where every individual piece is doing something understandable, but the system as a whole lies to you.
The fix was not to refresh harder
The tempting fix would have been: "Just fetch the whole conversation every time the chat opens."
That would probably work. It would also make the app feel a little heavier, especially in a realtime chat where the socket already handed us the message. Pulling from the backend after receiving the exact event felt like paying twice for the same information.
The better fix was to make the socket path update both layers of state:
So when a socket message arrives, I now build a real ChatMessage from the socket payload:
final incomingMessage = ChatMessage(
id: messageId,
message: content,
timestamp: timestamp,
isSender: senderId == currentUserId,
messageType: resolvedMessageType,
attachmentIds: attachmentIds,
tempId: tempId,
status: status,
);
Then I merge it into the preview's cached messages:
List<ChatMessage> mergeMessageIntoCache(
List<ChatMessage> currentMessages,
ChatMessage incomingMessage,
) {
final nextMessages = List<ChatMessage>.from(currentMessages);
final existingIndex = nextMessages.indexWhere(
(message) =>
message.id == incomingMessage.id ||
(incomingMessage.tempId != null &&
message.tempId == incomingMessage.tempId),
);
if (existingIndex == -1) {
nextMessages.add(incomingMessage);
} else {
nextMessages[existingIndex] = incomingMessage;
}
nextMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
return nextMessages;
}
The tempId part matters because optimistic UI is usually lurking somewhere nearby in chat apps. A sent message may appear locally before the server returns its final id. If the server later echoes it back, you do not want a duplicate bubble. Matching by id and tempId keeps that handoff cleaner.
Now the preview update includes the actual message cache:
existing.copyWith(
latestMessage: latestMessage,
time: timestamp,
unreadCount: shouldIncrement ? existing.unreadCount + 1 : 0,
initialMessages: mergeMessageIntoCache(
existing.initialMessages,
incomingMessage,
),
);
The result is less dramatic than the bug: the user taps the chat, the message is already there, and the read logic can do its job.
Lovely when software becomes boring again.
Then attachments joined the conversation
After that, another small issue showed up.
Text messages were fine, but when a new image or voice note arrived, the live preview said:
Attachment
After a manual refresh, it correctly said:
Image
or:
Voice note
Same screen. Same message. Different label depending on whether it came from the socket path or the refresh path.
That pointed to another mismatch.
The refresh path had more context. It could inspect or infer attachment metadata and resolve the message type properly. The socket path was mostly trusting the raw type field from the event. If the type was missing, generic, lowercase, or not specific enough, the UI fell back to Attachment.
This is one of those details that is easy to miss because the fallback is not broken. "Attachment" is technically acceptable. It just feels unfinished, especially when the same message becomes "Image" after refresh.
So the socket preview path needed to use the same resolver as the loaded conversation path:
final attachmentIds = extractAttachmentIds(messageData);
final resolvedMessageType =
attachmentIds.isNotEmpty
? await resolveMessageType(rawType, attachmentIds)
: mapMessageType(rawType.toUpperCase());
final latestMessage =
content.isNotEmpty
? content
: labelForMessageType(resolvedMessageType);
That made the live preview and refreshed preview agree.
The important thing here is not the label. It is the consistency of the data pipeline. If one path says "Image" and another says "Attachment", users may not describe it as a data pipeline issue. They will just say, very fairly, "This screen is weird."
Client and vendor were the same bug twice
This app has both client and vendor versions of the messages screen. They are not identical screens in the product sense, but their chat-preview logic is similar enough that fixing only one side would have been lazy.
So the same cache merge and attachment preview fix went into both message screens.
This is where duplicated UI logic starts tapping you on the shoulder.
Duplication is not automatically evil. Sometimes two flows really do need room to evolve separately. But realtime chat logic is the kind of thing that gets expensive when copied around. Socket payload parsing, attachment normalization, message deduping, unread count handling, and read-status behavior are all easy to get almost-right in two places.
Almost-right is where bugs breed.
I did not do the larger refactor today, but the shape is obvious: this logic wants to live somewhere shared. Not necessarily a giant abstraction. Maybe just a small mapper/helper that turns socket payloads into ChatMessage and preview metadata. Enough to stop the client and vendor screens from drifting apart.
There is a difference between noticing a refactor and doing it immediately. Today the right move was to fix the bug tightly, verify both screens, and leave the larger cleanup as a clear next step.
The part I keep thinking about
The bug was not caused by Flutter being strange. It was caused by state having two representations:
preview state
detail state
The preview state changed first, so the UI looked alive. But the detail state stayed stale, so the next screen felt broken.
That is a very mobile-app kind of bug.
Mobile interfaces often optimize for immediacy. We cache, prefill, optimistic-update, debounce, hydrate, and subscribe to streams. All of that is normal. But once there are multiple ways for the same entity to enter the UI, each path has to preserve the same meaning.
A message cannot only be a string in one path and a full object in another.
An attachment cannot be "whatever the raw event said" in one path and "resolved from metadata" in another.
Unread state cannot be cleared visually if the message id never reaches the code that persists the read receipt.
None of these are huge architectural revelations. They are smaller than that, and maybe more useful because of it.
Today's reminder: realtime UI bugs are often not about receiving data. They are about receiving data into the wrong layer of state.
And yes, sometimes the message is there.
Just not where the next screen is looking.