Images That Vanished, Then Came Back
Today’s Flutter session was about images that vanished, then returned. It turned into a lesson about optimistic state, message reconciliation, and how tiny payload differences ripple through UI.
I like days where the app acts weird for no obvious reason. It’s annoying in the moment, but it usually means there’s an interesting edge case hiding under the surface.
This one started with a tester saying: “I sent an image. It disappeared. Then it showed up later… on both ends.” I couldn’t reproduce it. My own tests looked fine. But that kind of report never comes from nowhere; it’s almost always a race between state and truth.
In the chat UI, we already had a working image send flow, so the bug wasn’t “image sending is broken.” It was subtler: the UI had enough moving parts that an “almost correct” message could take over and temporarily hide the actual attachment.
The part that actually mattered
The chat screen uses optimistic rendering. When you hit send, the UI immediately inserts a message card and an image bubble using the local file path. That makes the app feel snappy.
But the server also echoes a “real” message back over sockets once it’s stored. That message becomes the source of truth. Normally the optimistic message gets replaced by the server message, so the UI shows the final attachment ids instead of the local file.
The trouble starts when the server echo arrives before the attachment ids are present in the payload. In that case, the incoming message looks like “empty content, no attachments.” Our reconciliation logic was doing a blunt replacement anyway. So the optimistic image bubble gets replaced by a message with no attachments and… the image disappears.
Later, when the message is re-fetched (or another socket payload arrives with attachment ids), the image “comes back.” From the user’s perspective it’s spooky. From the code’s perspective, it’s just the wrong data winning for a minute.
The fix wasn’t “delay the echo” or “sleep before swapping.” It was to be conservative when merging optimistic messages with server messages. If an optimistic message already has attachments and the incoming echo doesn’t, keep the existing attachment details and only update what we know is trustworthy (id, status, timestamp).
Here’s the distilled idea:
fun mergeIncoming(existing: ChatMessage, incoming: ChatMessage): ChatMessage {
val incomingHasAttachments = incoming.attachmentIds.isNotEmpty()
val existingHasAttachments = existing.attachmentIds.isNotEmpty() ||
existing.localAttachmentPaths.isNotEmpty() ||
existing.localAttachmentBytes.isNotEmpty()
val preserveExisting = existingHasAttachments && !incomingHasAttachments
return ChatMessage(
id = incoming.id,
message = if (incoming.message.isNotEmpty()) incoming.message else existing.message,
timestamp = incoming.timestamp,
isSender = incoming.isSender,
messageType = if (preserveExisting) existing.messageType else incoming.messageType,
attachmentIds = if (incomingHasAttachments) incoming.attachmentIds else existing.attachmentIds,
localAttachmentBytes = if (incomingHasAttachments) emptyList() else existing.localAttachmentBytes,
localAttachmentPaths = if (incomingHasAttachments) emptyList() else existing.localAttachmentPaths,
localAttachmentSizes = if (incomingHasAttachments) emptyList() else existing.localAttachmentSizes,
attachmentNames = if (incomingHasAttachments) emptyList() else existing.attachmentNames,
localAttachmentTypes = if (incomingHasAttachments) emptyList() else existing.localAttachmentTypes,
tempId = incoming.tempId ?: existing.tempId,
status = incoming.status
)
}
Nothing fancy. Just a rule: don’t throw away local attachment state unless the server explicitly gives you a better one.
The unexpected detour: the “Documents” picker
While I was in there, I realized another frustration that wasn’t exactly a bug, but felt like one. Android’s document picker lets you choose images, even if you tapped “Documents.” Our code saw an image file, didn’t recognize it as a document, and popped an “unsupported file” snackbar.
That’s a user-facing paper cut. It also felt unnecessary because we already have a working image send flow. So the fix was to classify common image extensions as images even when they’re selected from the document picker. Same send pipeline, different label.
That change didn’t need a new upload path, just a tiny change in classification:
val messageType = when {
extension in imageExtensions -> MessageType.image
extension in documentExtensions -> MessageType.document
else -> null
}
if (messageType == null) {
skippedFiles++
continue
}
Now images picked from “Documents” behave like images, not errors.
Why this wasn’t as simple as it looked
Two things made this kind of slippery:
Socket payload shape drift. The attachment ids sometimes arrived under different keys depending on the event. If your extraction logic only checks one shape, you will silently treat a valid attachment as missing. That’s a “logic bug” disguised as “flaky sockets.”
Optimistic UI is a double-edged sword. It makes the app feel fast, but it also introduces “two sources of truth.” If you don’t merge them carefully, you get UI flicker or temporary data loss.
I didn’t add complicated retries or delays. I just made the client more conservative about discarding local attachment state and slightly more flexible in how it reads attachment ids.
What I’m taking away
I didn’t learn a new Flutter API today. I learned (again) that “message reconciliation” is a product feature, not a background implementation detail. The user’s trust depends on it.
Also, the fastest UI isn’t the one that renders first. It’s the one that doesn’t lie to the user in the moments between optimistic state and server truth.
Tomorrow I’ll probably chase something totally different, but I’m glad I didn’t shrug this off as “couldn’t reproduce.” It was real. It was weird. It deserved a thoughtful fix.