For the past 5 years Flabbergast has been collaborating with Tools For Humanity, working on the mobile app called World App, which enables users to securely and anonymously prove that they are a unique human in an online world. Last year, we took on one of the most challenging projects in the World App called World Chat. Being a human-unique place, the World App makes the best environment to build a bot-free chat that only real humans can use. To achieve the high standards of privacy and security, we faced strict privacy constraints that required fully end-to-end encrypted messaging with guarantees comparable to Signal. To meet these requirements, we used XMTP’s framework. XMTP is an open protocol and decentralized network that enables developers to build secure messaging experiences where users truly own their identity, conversations, and data. In this article, we are going to present our experience working with XMTP protocol and its SDK for iOS, and share the lessons we learned along the way that might help future builders who use XMTP in their projects.
XMTP’s iOS SDK documentation was well written and easy to follow, but when we started building a real messaging experience, we ran into challenges whose solutions couldn’t be inferred from the official docs, especially around how chat messages are actually sent and handled in practice. At first, sending and receiving messages doesn’t look overly complicated, but if you want to build a great experience for several million users, it’s the most crucial thing that a chat app has to get just right, and there’s more to it than the basic examples in the docs suggest.
The APIs we’re working with
Let’s start by looking at the official docs and the APIs we’re actually working with. There are several different methods that work with messages that all return different types.
First up, we have the message stream:
public func streamMessages(onClose: (() -> Void)? = nil) ->
AsyncThrowingStream<
DecodedMessage, Error
>
This method gives us a live stream of messages and emits a new value every time there’s a new message created.
Next is the simplified version of another important method, enrichedMessages:
public func enrichedMessages(
limit: Int? = nil,
beforeNs: Int64? = nil,
afterNs: Int64? = nil,
direction: SortDirection? = .descending,
deliveryStatus: MessageDeliveryStatus = .all,
excludeContentTypes: [StandardContentType]? = nil,
excludeSenderInboxIds: [String]? = nil,
sortBy: MessageSortBy? = nil,
insertedAfterNs: Int64? = nil,
insertedBeforeNs: Int64? = nil
) async throws -> [DecodedMessageV2]
This one looks more straightforward; it fetches a list of messages for a conversation directly from the local database on the device. The local database managed by XMTP’s framework serves as the single source of truth for a user’s historical messages.
And finally, there is a method for creating new messages called send:
@discardableResult
public func send<T>(
content: T,
options: SendOptions? = nil,
fallback _: String? = nil
) async throws -> String
All three of these methods return different types, each being a different representation of the message. That’s intentional, and it’s worth understanding why. DecodedMessage is a lightweight, stateless representation of a message and XMTP uses this model for streaming new messages to keep things fast and responsive. That’s why DecodedMessage doesn’t include any contextual data. For example, reactions will always be empty, and reply messages won’t include the content of the original message they reference.
DecodedMessageV2, also referred to as the enriched message, fills in those gaps. When messages are fetched from the local database through the enrichedMessages method, the SDK returns messages with additional data like reactions and reply content, giving you a much more complete view of the conversation.
Finally, the send function returns a simple String, which is the message’s identifier.
This ID stays consistent across both the streamed (DecodedMessage) and enriched (DecodedMessageV2) versions of the message and serves as a link between the two.
The Domain Model: Why It Matters
XMTP provides a versatile message model, and the official docs explain how to integrate it directly into your app and UI. However, that model is designed to work for a wide variety of apps, which can be both a blessing and a curse. It can be overkill to have many fields and structures your app might never use, while on the other hand, it might lack certain functionalities you need, like custom content types, reactions handling, or additional metadata. One of the first things we figured out is that having a clear domain model is essential when building a messaging app — one where key concepts such as messages, conversations, and users are defined in a way that fits your app’s specific needs. Having a domain model independent of the framework and tailored to your app is valuable because it lets you:
- Include only the properties and behaviors your app actually needs. By keeping your domain model focused on what matters to your app, you avoid unnecessary complexity and potential bugs. For example, XMTP messages have a generic contentType property typed as Any. In our app, we mapped it to an enum representing only the content types we actually support. This allows us to throw an error if an unexpected type appears, rather than silently ignoring it or failing unpredictably. Similarly, if your app doesn’t use features like reactions or media attachments, there’s no reason to include those properties in your model.
- Easily extend the model with app-specific functionality. A tailored domain model makes it straightforward to add logic that’s unique to your app. In the World App, for instance, users could exchange and open gifts — actions not covered by XMTP’s default message types. By extending our domain model to include GiftMessage and associated logic, we could implement this feature cleanly without hacking the framework’s base objects.
- Simplify your code and make it more predictable. When your domain model captures exactly what your app needs, it reduces the number of edge cases and boilerplate code. You don’t have to check for unused properties or handle generic types everywhere. For instance, having a username property directly on a message object means you don’t need to query the inbox separately just to display a sender’s name, making message handling straightforward and predictable.
- Reduce friction when mapping messages to your UI. All the properties your UI requires can live on the domain objects, so you don’t need additional transformations or lookups. For example, instead of fetching the sender’s username from another service or table, the domain model already includes it, allowing your UI to render messages immediately.
Here’s a simple version of how we can model a Message in our chat system:
extension Chat {
public struct Message {
public let id: MessageID
public let conversationID: ConversationID
public let senderInboxID: InboxID
public let senderUsername: String
public let sentAt: Date
public var contentType: ContentType
public var reactions: [Reaction] = []
public let myInboxID: InboxID
public var isPublished: Bool
}
}
For content, we keep it straightforward with a small set of types:
extension Chat {
public enum ContentType {
case text(content: String)
case reaction(emoji: String, messageID: MessageID)
case groupRename(newName: String)
case reply(content: String, replyingMessageID: MessageID)
case deleteMessage(messageId: MessageID)
case unknown
}
}
The simple model tailored to our app gives us clarity, type safety, and flexibility. It’s easy to work with, and still enables clean mapping of XMTP messages to our data structures when needed.
Sending Optimistic Messages
After we have our model defined, let’s look at how we handle the crucial part of the messaging app: message sending. When a user sends a message with XMTP they might experience a delay between sending the message and seeing it appear in the UI. The delay is caused by the network request that processes the message. In order to display the sent message immediately, we need to implement optimistic message sending. For this use case, XMTP provides a prepareMessage function. Unlike sendMessage, which publishes the message to the network, prepareMessage creates and stores the message locally in the app’s database without broadcasting it. Here’s an example of optimistic message sending copied from the XMTP docs, including their code comments:
// Publish all pending optimistically sent messages to the network
// Call this only after using prepareMessage to send a message locally
func sendMessageWithOptimisticUI(conversation: Conversation,
messageText: String) async throws -> Bool {
do {
// Add message to UI immediately
try await conversation.prepareMessage(messageText)
// Actually send the message to the network
try await conversation.publishMessages()
return true
} catch {
print("Failed to send message: \(error)")
return false
}
}
You might expect that if you’re subscribed to streamMessages, you would receive the message immediately after calling prepareMessage and be able to show it in your UI right away. But that is not what happens — the message is published through the stream only after it’s actually processed through the network call, which can take some time. Not very optimistic, right?
So, if you’re displaying messages in something like a UICollectionView using your own domain model, you’ll need to manually create a new message as soon as it’s prepared by prepareMessage, using just the message ID returned by the function. This ensures the UI updates immediately, providing instant feedback to the user while the network call happens in the background.
We can achieve this by adding a convenience initializer to our Message model that requires only the message ID and a small set of essential information. This “basic info” includes the data you already need when creating a message to send, such as:
- The conversation ID the message belongs to
- The user’s inbox ID
- The message content itself (e.g., a plain text string for a text message or raw PNG data for an image)
From the content, you can also infer the content type (text, image, etc.) for your message. For reactions or other supported types, we can similarly map them to the correct domain content type. Other message properties can also be inferred:
- sentAt can be set to the current time.
- isPublished starts as false because the message hasn’t been published yet.
- conversationID, myInboxID, and senderInboxID can be inferred from the given active conversation.
- reactions can start as an empty array.
With Swift’s concurrency features, we can return this message immediately while sending it in the background. A simple Task is all we need.
Here’s how optimistic messaging might actually look like:
func sendMessageWithOptimisticUI(conversation: Conversation,
messageText: String) async throws -> Chat.Message {
// Prepare the message locally and get the message ID
let messageId = try await conversation.prepareMessage(messageText)
// Create a domain message that can be added to the UI immediately
let domainMessage = Chat.Message(
id: messageId,
sentAt: Date(),
conversation: conversation,
contentType: .text(content: messageText) // or a mapper to the correct contentType
)
// Publish the message to the network in the background
Task {
try await conversation.publishMessages()
}
return domainMessage
}
Once we have sent an optimistic message, the last step is to wait for XMTP to confirm it. When that happens, a new message is published through streamMessages and we can then swap out the non-published version in our UI data source with the real, confirmed message. There is additional complexity around optimistic message sending because we need to handle the case when the network call fails. We will cover that topic along with media message sending in more detail in the second part of this blog series. Now let’s look at how we receive messages with XMTP.
Receiving Messages
When it comes to receiving conversation messages, it is important to go back to the difference between Conversation’s enrichedMessages and streamMessages methods. streamMessages emits every message as it’s received in real time — including reactions, text messages, read receipts, etc. — which are all treated as messages in the stream. enrichedMessages returns only the messages that correspond to visible message cells in your UI. Each enriched message includes not just the main content, but also associated reactions, any read receipts, and if the message is a reply — the original message it references.
So how does this affect our implementation?
When we open the chat conversation we first make an initial call to conversation.enrichedMessages(), whose response is parsed to our domain model and used as the UI data source. New messages that arrive through streamMessages can’t be directly inserted into our UI data source because some messages are “meta” (like reactions, read receipts) and shouldn’t appear as separate cells. One possible solution would be to treat enrichedMessages as the only source of messages and call it every time a new message arrives through streamMessages. However, in a busy app with millions of users you might receive multiple messages in multiple conversations at any second, so this approach quickly slows down and causes lags. Shoutout to our friend Andy at TFH, who, while leading the project, built a test web app that let us stress-test our implementation by bombarding the client with hundreds of messages per second.
The best approach is to think of data returned by enrichedMessages as the main data source and the messages emitted by streamMessages as actions that modify the data source. Each emitted message can affect the data differently, depending on its content type:
- .text messages get appended to the data source.
- .reaction messages update an existing message in the data source.
- .deleteMessage messages remove an existing message from the data source.
enrichedMessages are only called when opening a conversation or paginating through older messages. Both enrichedMessages and streamMessages, even though they return different types of data, update our domain model which the UI uses to display the chat. This approach achieves the smooth and fast UI that we want our users to experience.
Mutating Conversations
Messages aren’t the only things that change over time — conversations themselves can mutate too. To handle conversation changes we use conversationStream, which publishes conversation changes.
class Conversation {
...
public func stream(
type: ConversationFilterType = .all,
onClose: (() -> Void)? = nil
) -> AsyncThrowingStream<
Conversation, Error
>
}
Looking at the official docs, we assumed that any change to a conversation would be emitted through this stream: new members, renames, metadata changes, everything.
That assumption turned out to be very wrong. In reality, the conversation stream only emits new conversations. It doesn’t emit updates for changes happening inside existing conversations.
So we ended up taking the same approach as we did with messages. We built our own mutation layer for conversations. Instead of relying on the conversation stream for updates, we treat certain messages emitted by streamMessages as actions that mutate the conversation model itself. Here is a simple example of a conversation domain model:
public struct Conversation {
public let id: ConversationID
public let type: ConversationType
public let userRole: Role
public let createdAt: Date
public var topic: String
public var members: [InboxID: Member]
public var metadata: GroupMetadata?
private(set) public var lastMessage: String
public var consentState: ConsentState
public var lastReadTimes: [InboxID: Date]
public var unreadCount: Int
public var lastActivityDate: Date
}
A few examples of how emitted messages can update the conversation domain model:
- Group membership updates → mutate the members property on the conversation model
- Group rename → update the conversation’s topic
- Other group-related events → map them to domain-level conversation changes
So the messages from the stream mutate the message data source, and certain messages from the stream also mutate the conversation data model.
Conclusion
While XMTP provides a good foundation with versatile message models and streaming capabilities, creating a smooth, responsive chat UI requires more than just plugging in the SDK. The key takeaways from this article:
1. Use a domain model tailored to your app
XMTP’s message models are powerful, but they can be overkill or sometimes lack exactly what your app needs. By creating your own Message model, you get type safety, clarity, and flexibility.
2. Implement optimistic messaging
Users expect instant feedback. By creating domain messages immediately and sending them in the background, you can show new messages instantly. Then, once XMTP confirms the send, swap the optimistic message with the published one for full accuracy.
3. Handle streamed updates smartly
Think of streamMessages as actions on your core data source (enrichedMessages). Some messages append new cells, some update existing ones, and some affect the conversation itself. This approach keeps your UI efficient, responsive, and correct.
The quality threshold for delivering a chat app that people will trust and want to use is very high, so optimizing it wherever possible is a must. By combining a custom domain model, optimistic sends, and smart handling of streams, you can build a chat experience that feels instant, reliable, and tailored to your app’s needs — while still leveraging the power of XMTP under the hood.
Author
Juraj Pavlek
CATEGORY
Published
19.03.2026.