View more

Optimistic Media Messages for XMTP Chat: Fake it till you make it

At Flabbergast, we’ve been building World Chat; secure, end-to-end encrypted messaging between verified humans. XMTP provides a strong foundation out of the box. It handles encryption and delivery, so we can focus on building a smooth and reliable product.

In our previous post, we covered the basics: modeling chat data, sending optimistic text messages, and keeping the UI responsive. This post dives deeper into one part of that – optimistic attachment messages. Attachments introduce new challenges. Sending an image is no longer a single action. It’s a pipeline: compression, encryption, upload, and delivery. Each step adds latency, each step can fail. What seems simple quickly becomes non-trivial.

In this post, we explain the tradeoffs we navigated, the approaches that didn’t work out, and the solution we landed on: fake IDs, ID mapping, partial failure handling, and persistence across app restarts. It’s likely messier than you’d hope, but more deliberate than it looks.

When it comes to chat apps, it’s all about instant feedback. Users expect the interactions to be effortless and responsive. It’s no different when it comes to sending images or videos. The flow should be simple: select an attachment, tap send, see it appear. Unfortunately for developers, someone has to draw the short straw and build all the messy machinery behind that simplicity.

Before an attachment ever makes it to the other side, it has to go through compression, encryption, network uploads, and delivery – a process that can easily take several seconds. Thankfully, XMTP gives us a rock-solid, end-to-end encrypted messaging layer and a great foundation to build on. But even with all of that, there’s still a UX gap to bridge.

The seemingly simple problem

At first glance, sending an attachment seems simple. There might be a few optional data processing steps (e.g. compression), but the core flow is non-negotiable: encrypt the data, request a presigned upload URL, upload the encrypted payload, and finally send the message.

func sendAttachment(attachment: Attachment) async throws {
	// Step 0: Optional data processing
	let processed = try await compress(attachment)

	// Step 1: Encryption
	let encrypted = try xmtp.encode(content: processed)

	// Step 2: Get presigned URL - network call
	let uploadUrl = try await getUploadUrl(for: encrypted.digest)

	// Step 3: Actual upload to server - network call
	try await upload(to: uploadUrl, data: encrypted.payload)
    
	// Step 4: Prepare the message
	let messageId = try await xmtp.prepareMessage(uploadUrl, encrypted)

	// Step 5: Publish it
	try await xmtp.publishMessages()

	// Final: Show it in the UI
	let message = createMessage(messageId, attachment)
	showInChat(message)
}

None of these steps are especially complex on their own. The problem is that some of them can take seconds, and each can fail independently. If the users don’t have any indication of the ongoing process and its status, the app doesn’t just feel slow, it feels broken.

The common solution is to use a placeholder: show something in the chat immediately, then replace it if the upload fails or succeeds. The question is – what is that something? Well, obviously, we would want to show the actual attachment message that is being sent. The issue is – as seen in the code above, we get the actual message in the final step, after all the time consuming steps.

Single attachment – multiple approaches

It’s clear that the final step (creating the message and displaying it in the UI) needs to happen earlier in the process. One approach might be to move steps 3 and 5 (uploading and publishing) to the end of the function. That’s fine assuming everything succeeds. However, if the upload fails, we’ll end up with a prepared message referencing an attachment that doesn’t exist on the server. Since XMTP’s publishMessages publishes all prepared messages in the local database, it’s only a matter of time before that message is sent – potentially by another part of the app.

An interesting feature that solves this issue is the controlled publication of optimistic messages. If you set the noSend parameter in the prepareMessage function to true, it allows you to prepare a message that will not be published unexpectedly. Back when we were building this, controlled publication didn’t exist yet. And even if it did, all that would allow us is to reorder steps 3-5, but steps 0-2 could still take noticeable time to finish.

func sendAttachment(attachment: Attachment) async throws {
	// Same steps 0-2
	let processed = try await compress(attachment)
	let encrypted = try xmtp.encode(content: processed)
	let uploadUrl = try await getUploadUrl(for: encrypted.digest)

	// Prepare the message and show it in the UI
	let messageId = try await xmtp.prepareMessage(noSend: true, ...)
	let message = createMessage(messageId, attachment)
	showInChat(message)

	// Then do the upload and publish
	do {
		try await upload(to: uploadUrl, data: encrypted.payload)
		try await xmtp.publishMessage(messageId: messageId)
	} catch {
		try await xmtp.deleteMessageLocally(messageId: messageId)
	}
}

Let’s take a closer look at the first three steps. Compression (step 0) is optional, so we can skip it for this example. Encryption (step 1) is required, but it’s relatively fast and it doesn’t noticeably impact the user experience, so we can set it aside as well. That leaves step 2: a network call to fetch the presigned upload URL. To get an actual message from XMTP, we need both the encrypted content and that URL, but we don’t want our users waiting for those values to be computed or fetched. That leaves only one option: we have to create our own placeholder message. Normally, this would be straightforward, but as you’ll soon see, there’s a reason we tried to avoid it.

We do have some of the data needed for a placeholder: the attachment URL, the sender and the sent time. But there’s one crucial piece we cannot know in advance: the message ID. Message IDs are the backbone of our chat UI. They provide the stable identity that keeps the collection view consistent, they drive diffing, scrolling, cell reuse and animations. Every insertion and update relies on each message having a durable and unique identifier.

Ideally, XMTP would give us the message ID upfront, or at least allow us to assign one manually. The reality is that message IDs are content-dependent: at some level they’re derived from the encrypted payload itself. In other words, the ID doesn’t exist until the message content is fully prepared. That’s why we decided to settle with our last resort – fake IDs.

func sendSingleAttachment(attachment: Attachment) async throws {
	// Create the optimistic message with a fake ID
	let optimisticMessage = createOptimisticMessage(attachment)
	showInChat(optimisticMessage)
	do {
		let realMessage = try doTheHeavyWork()
		// Remember which fake ID corresponds to which real message
		connectIds(
			optimisticId: optimisticMessage.id,
			realId: realMessage.id
		)
	} catch {
		markAsFailed(optimisticMessage)
	}
}

We create a local placeholder message with a fake, client-generated ID and immediately show it in the chat. If the upload process succeeds, we produce the real message and get the final, content-derived message ID. We link the optimistic message to the real one, which is the key part handled by the connectIds function.

Maybe a bit contrary to expectations, we don’t remove the optimistic message from the UI. Its ID is already tied to the collection view and local state, so swapping it out could cause flicker or jumps. Instead, we simply remember that this optimistic ID corresponds to that real XMTP ID. From that point on, sent status, read receipts, reactions and other updates arrive through the XMTP messages stream. All those events carry the real message ID, which can easily be mapped to the corresponding optimistic message.

If the upload fails, we mark the placeholder message as failed and show a retry option. And, that’s it! For a single attachment, the outcome is binary, it either uploads or it doesn’t. This elegance doesn’t scale to multiple attachments. Once you move beyond a single file, failure stops being binary.

Multiple attachments – one size doesn’t fit all

Some of the things we considered and implemented for single attachments don’t apply to multiple attachments and the main reason is – partial failure. Even if we had an option to know the message ID in advance, it wouldn’t help, because partial failure would require an entirely new message, and more importantly, new message ID. The same reason applies as to why controlled publication isn’t of much use here.

func sendMultAttachment(attachments: [Attachment]) async throws {
	let optimisticMessage = createOptimisticMessage(attachments)
	showInChat(optimisticMessage)

	var succeeded: [Attachment] = []
	var failed: [Attachment] = []

	for attachment in attachments {
		do {
			try await doTheHeavyWork()
			succeeded.append(attachment)
		} catch {
			failed.append(attachment)
		}
	}

	if !succeeded.isEmpty {
		// If any succeeded, send them in the message and map the IDs
		let realMessage = try await prepareAndPublishMessage(succeeded)
		connectIds(
			optimisticId: optimisticMessage.id,
			realId: realMessage.id
		)
	} 

	if !failed.isEmpty {
		// If all failed, mark the placeholder as failed
		if succeeded.isEmpty {
			markAsFailed(optimisticMessage)
		} else {
			// If only some failed, create a new failed placeholder
			let failedMessage = createOptimisticMessage(failed)
			showInChat(failedMessage)
			markAsFailed(failedMessage)
		}
	}
}

Just like with a single attachment, we begin by creating and showing an optimistic placeholder with a fake ID. Each attachment is uploaded independently, sequentially or in parallel, it doesn’t really matter. What matters is the final outcome. The binary cases are boring and already familiar: everything succeeds – the optimistic message resolves into a real one; everything fails – it fails as a whole. The interesting case is partial failure. Once all uploads finish, we split the result into two groups: attachments that succeeded and attachments that failed.

If at least one attachment succeeded, we create and send a real XMTP message containing only the successful uploads. Again, as with the single attachment message, we connect the real and optimistic message IDs while keeping the optimistic message in the UI. Unlike the single attachment case, the optimistic message doesn’t initially contain the correct final content (due to its optimism, it contains the failed uploads too). We rely on the XMTP message stream to fix that. When the real message arrives, we use the ID mapping and map its content to the existing optimistic message, which causes the UI to update.

Failed attachments are handled separately. Rather than mutating the original optimistic message, we create a new optimistic message in a failed state that represents only the attachments that didn’t upload. This is where the terminology starts to break down: an optimistic failed message is an obvious contradiction. Internally, we treat these less as optimistic messages and more as what they really are: fake, UI-only, messages used solely for presentation and recovery.

Persisting all the hard work

Sending multiple images or videos can take a noticeable amount of time, and users may leave the chat, or even the app, while uploads are still in progress. That raises two practical questions: how long do optimistic placeholders stick around; and what happens if the app is terminated mid-upload?

As already said, we do not replace the optimistic message in the UI as soon as the real message is created. Doing so would risk UI glitches when the real message arrives. To avoid that, the app tracks ongoing uploads via uploadingMessages(for:). Once the upload process succeeds, the corresponding optimistic message is removed from the uploading service, but it is intentionally not removed from the UI. The real XMTP message becomes the source of truth, and the ID mapping is still required so that stream updates (sent status, reactions, read receipts…) are applied to the existing UI cell. The optimistic message disappears from the UI only after the user leaves the chat screen. At that point, it is no longer in memory, so it cannot be recreated when the conversation is reloaded. The real XMTP message is rendered instead. On the other hand, if an upload fails and the user deletes the message attempt, the placeholder is removed from both the UI and memory immediately.

public func uploadingMessages(
	for conversationId: ConversationId
) -> AsyncStream<[UploadingMessages]> {
	AsyncStream { continuation in
       	let cancellable = $uploadingMessagesByConversation
            .compactMap { $0[conversationId] }
            .sink { continuation.yield($0) }
      		continuation.onTermination = { @Sendable _ in
        	cancellable.cancel()
		}
	}
}

We use AsyncStream here as a transport layer. It gives us a typed, cancelable stream of uploads that fits naturally into our architecture. This stream doesn’t tell the UI what changed, it tells it what is currently uploading. The UI receives a snapshot of all new and updated uploading messages each time the stream emits. Because it only gets the active or updated messages, any optimistic messages that have already been uploaded and sent are likely missing from the stream. However, the presentation layer can still have those messages in memory, so it can keep their cells visible until the user leaves the screen.

Because these processes can last longer than a single app session, we also have to handle the interruption. Users can background the app mid-upload, and iOS can terminate it at any time. The solution is persistence. Every optimistic message, along with its fake ID and upload state, is written to disk as soon as it’s created. On app launch, that persisted state is reloaded into uploadingMessagesByConversation, and the optimistic messages are reconstructed in the UI exactly where they were before. From there, uploads can be resumed or failures shown. The key thing is that, from the user’s perspective, messages never vanish without explanation.

And, finally…

Optimistic placeholders with fake IDs aren’t just a “hack”, they’re a deliberate UX tradeoff. Using fake IDs means accepting a temporary divergence between UI state and protocol state: for a short period of time, the UI renders messages that do not, and cannot, exist on the XMTP network. We make that tradeoff intentionally.

All alternative approaches considered here produce worse UX and more fragile code. By introducing explicit ID mapping, we restrict this divergence to a small, well-defined boundary. UI messages are optimistic and short-lived; protocol messages are authoritative and durable. The mapping layer is what allows both to coexist without leaking complexity into the rest of the system.

The result is a chat experience that feels immediate even when the underlying work isn’t. Upload progress is visible, partial failures are recoverable, and in-flight messages survive app restarts. In a world where “sending” can take seconds, faking it, carefully and deliberately, is how you make it feel instant.

Author

Iva Marić

CATEGORY

App ArchitectureDevelopmentiOS

Published

15.04.2026.