Dual-state messages for optimized user experience and data consistency
This article explains the temporary messages that optimistically shows messages immediately to users while ensuring they are properly persisted to the database.
Optimistic updates are a pattern where the frontend "predicts" success to provide an instantaneous user experience.
Without temporary messages, users experience significant lag when sending messages:
Traditional flow:
User types → User sends →
Wait for server... →
Database saves →
Server responds →
User sees message
Feels slow
With temporary messages:
Optimized flow:
User types → User sends →
INSTANTLY show message (temp) →
Background: Save to database →
LATER: Replace temp with persisted version
Feels instant
Temporary Message Lifecycle
When the user clicks "Send", the frontend generates a Temporary ID and pushes the message to the list with a SENDING status. This happens on the client-side memory, and there is no server involvement for temporary messages.
The client stores these "in-flight" messages in memory. This allows the application to keep track of which UI elements are waiting for a database confirmation without relying on the server to keep that state.
The actual message is sent to the server via the MESSAGE_SEND event.
// Frontend: Composition
const messages = ref<Message[]>([])
const tempMessages = new Map<string, Message>()
const sendMessage = (content: string) => {
const tempId = `temp-${Date.now()}-${Math.random()}`
// Create optimistic message
const tempMessage: Message = {
id: tempId, // Temporary ID
content,
sender_id: currentUser.id,
created_at: new Date(),
status: 'SENDING', // Not yet persisted
temporary: true,
}
// Show immediately (optimistic update)
messages.value.push(tempMessage)
tempMessages.set(tempId, tempMessage)
// Scroll to message
scrollToBottom()
// Send to server
socket.emit(
'MESSAGE_SEND',
{
tempId,
content,
channelId: currentChannel.id,
},
response => {
if (!response.success) {
// Remove if send failed
removeMessage(tempId)
showError('Failed to send message')
}
}
)
}
Once the server responds, the client reacts based on a success or failure.
In case of success, the client uses the tempId to find the local message and swaps it with the real database record, with the actual message ID and delivered status.
In case of failure, the client shows a "failed to send" status.
// Frontend: Handle persisted message
socket.on('messagePersisted', data => {
const { tempId, messageId, message } = data
// Find the temporary message
const index = messages.value.findIndex(m => m.id === tempId)
if (index !== -1) {
// Replace temp message with persisted version
messages.value[index] = {
...message,
id: messageId,
status: 'delivered',
temporary: false,
}
}
// Clean up temp storage
tempMessages.delete(tempId)
})
// Handle message failure
socket.on('messageFailed', data => {
const { tempId } = data
const message = messages.value.find(m => m.id === tempId)
if (message) {
message.status = 'failed'
}
})
Message State Transitions