A chat app is judged on one thing above all: messages must not get lost, and they must not appear twice. Mobile networks drop constantly, laptops sleep, and servers restart during deploys. In this part Relay gains the machinery that makes it trustworthy: an outbox on the client, safe retries, catching up after a disconnect, and receipts that tell senders what happened to their messages.
Delivery semantics, honestly
Distributed systems offer three delivery guarantees, and it is worth being precise about them because "exactly once" gets promised far more often than it is delivered.
- —At most once: send and forget. If anything fails, the message is lost. This is what a plain
socket.emitwithout an ack gives you. - —At least once: keep retrying until you get an acknowledgement. Nothing is lost, but when an ack is lost the message is sent again, so duplicates are possible.
- —Exactly once: each message is delivered and processed once. Over an unreliable network this is impossible to guarantee at the transport level.
The practical answer is at-least-once delivery plus idempotent processing, which gives you effectively-once behaviour. The client retries until it gets an ack, and the server uses the clientId from part 4 to recognise repeats. Receivers dedupe by message id. Every layer is allowed to see a message twice; none of them is allowed to act on it twice.
You cannot stop the network from repeating itself. You can make repetition harmless.
The client outbox
Every message the user sends goes into an outbox before it goes anywhere near the network. Each entry has a status: pending while it is in flight, sent once the server acked it, and failed after retries run out. The UI renders straight from the outbox, which is what gives users the familiar clock icon, single tick and retry button.
import { socket } from './socket';
import type { Message, SendPayload } from '../../shared/events';
export type OutboxEntry = SendPayload & {
status: 'pending' | 'sent' | 'failed';
attempts: number;
saved?: Message;
};
const MAX_ATTEMPTS = 6;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function deliver(entry: OutboxEntry, update: (e: OutboxEntry) => void) {
const { clientId, conversationId, body } = entry;
while (entry.attempts < MAX_ATTEMPTS) {
entry = { ...entry, attempts: entry.attempts + 1, status: 'pending' };
update(entry);
if (socket.connected) {
try {
// Same clientId on every attempt: the server dedupes retries.
const res = await socket.timeout(5000).emitWithAck('message:send', { clientId, conversationId, body });
if (res.ok) return update({ ...entry, status: 'sent', saved: res.data });
if (res.error !== 'server_error') break; // validation or auth: retrying will not help
} catch {
// Timed out: the server may or may not have it. Safe to retry.
}
}
// Exponential backoff with full jitter, capped at 30 seconds.
const cap = Math.min(30_000, 500 * 2 ** entry.attempts);
await sleep(Math.random() * cap);
}
update({ ...entry, status: 'failed' });
}Three details matter here. The clientId never changes between attempts, which is what makes the retry safe. Errors that retrying cannot fix, such as forbidden or invalid_payload, fail immediately. And jitter spreads retries out, so when a server restarts, thousands of clients do not all retry in the same millisecond.
The server ack returns the saved message
Part 4 already made the ack return the stored row, not just ok: true. That is important: the client swaps its optimistic copy for the saved message, adopting the server id and createdAt. From then on, the message sorts correctly among everyone else's and can be used as a pagination cursor. The swap is keyed on clientId, so it is safe even if message:new for the same message arrived first on another tab.
Catching up after a reconnect
The outbox covers the messages you send. The harder problem is messages you missed: anything broadcast to a room while your socket was disconnected is gone, because Socket.IO does not buffer room broadcasts for absent sockets. Since everything broadcast is in Postgres, the fix is to ask the database what you missed.
Each client remembers the newest message ID it has seen per conversation. After every reconnect, it asks for everything newer. Because IDs are time-sortable, this is the same keyset range scan as history, just in the other direction.
export async function since(conversationId: string, afterId: string, limit = 200) {
const { rows } = await pool.query<Message>(
`SELECT ${COLUMNS} FROM messages
WHERE conversation_id = $1 AND id > $2::uuid
ORDER BY id ASC
LIMIT $3`,
[conversationId, afterId, limit + 1],
);
return { messages: rows.slice(0, limit), hasMore: rows.length > limit };
}socket.on('connect', async () => {
if (socket.recovered) return; // Socket.IO replayed missed events for us
for (const [conversationId, lastSeenId] of lastSeen.entries()) {
await socket.emitWithAck('conversation:join', conversationId);
let cursor = lastSeenId;
let more = true;
while (more) {
const res = await api.get(`/conversations/${conversationId}/messages?after=${cursor}`);
mergeMessages(conversationId, res.messages); // dedupes by id
if (res.messages.length) cursor = res.messages.at(-1).id;
more = res.hasMore;
}
}
});Join the room first, then fetch. If you fetch first, a message sent between the fetch and the join is missed by both. Joining first can deliver a message twice, once live and once from the fetch, and mergeMessages deduping by id absorbs that. The order that risks duplicates is always preferable to the order that risks loss.
Connection state recovery
Socket.IO 4.6 added connectionStateRecovery. When enabled, the server keeps a short buffer of packets sent to each disconnected socket along with its rooms, and if the client reconnects within the window it gets its old socket.id, its rooms and the missed events replayed. The client can check socket.recovered to see whether it worked.
const io = new Server(httpServer, {
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000, // 2 minutes
skipMiddlewares: true,
},
});It is a nice optimisation for short blips, but treat it as exactly that. The buffer lives in server memory by default, so a restart or deploy wipes it. It only covers the configured window. And it depends on the adapter: the classic Redis pub/sub adapter used for scaling out does not support it, while the Redis Streams adapter does. The cursor-based catch-up above always works, so Relay keeps it as the source of truth and uses recovery to skip the fetch when it can.
Delivered and read receipts
Senders want to know more than "the server has it". Chat apps typically show three states: sent (server acked), delivered (reached the recipient's device) and read (the recipient actually saw it). Storing a row per message per recipient gets expensive fast in group chats. The trick is that reading is monotonic: if you have read message 500, you have read everything before it. So each member only needs one pointer per conversation.
socket.on('receipt:read', async ({ conversationId, messageId }) => {
if (!socket.rooms.has(`conv:${conversationId}`)) return;
const { rowCount } = await pool.query(
`UPDATE conversation_members
SET last_read_message_id = $3
WHERE conversation_id = $1 AND user_id = $2
AND (last_read_message_id IS NULL OR last_read_message_id < $3::uuid)`,
[conversationId, socket.data.userId, messageId],
);
if (rowCount) {
const receipt = { conversationId, userId: socket.data.userId, messageId };
socket.to(`conv:${conversationId}`).emit('receipt:read', receipt);
// Clear the unread badge on this user's other devices.
socket.to(`user:${socket.data.userId}`).emit('receipt:read', receipt);
}
});The last_read_message_id < $3 condition makes the update monotonic: a late or duplicate receipt can never move the pointer backwards. The client sends receipt:read when a message actually scrolls into view while the window is focused, throttled to at most once a second per conversation. Delivered receipts work the same way with a last_delivered_message_id column, sent when the client receives message:new; many apps skip them in group chats because they add traffic with little value.
Unread counts
With a read pointer, the unread count for a conversation is the number of messages newer than it, not sent by you. The (conversation_id, id DESC) index serves this directly. Cap the count so a busy group does not turn a badge into a large scan; nobody needs to know the difference between 99+ and 4,000.
SELECT count(*) FROM (
SELECT 1 FROM messages m
WHERE m.conversation_id = $1
AND m.id > coalesce($2::uuid, '00000000-0000-0000-0000-000000000000')
AND m.sender_id <> $3
LIMIT 100
) AS unread;What ordering you actually get
Relay guarantees a total order per conversation: every client sorts by server-assigned ID, so everyone sees the same sequence after catch-up. It does not guarantee order across conversations, and it does not need to. Live events can occasionally arrive out of order, for example when a retried message is acked after a newer one, so clients insert by ID rather than appending, and a sorted array with a binary-search insert keeps that cheap.
Key takeaways
- —Aim for at-least-once delivery with idempotent processing; that is effectively once.
- —An outbox with pending, sent and failed states drives both retries and the UI.
- —Retry with the same
clientId, exponential backoff and jitter; fail fast on non-retryable errors. - —After a reconnect, join first, then fetch everything after your last seen ID, deduping by
id. - —
connectionStateRecoveryis a short-window optimisation, not a replacement for cursor catch-up. - —Store one monotonic read pointer per member instead of a receipt per message.
Next up, part 6: Presence and Typing Indicators with Redis, where Relay learns who is online and who is typing, using fast ephemeral state that never touches Postgres.
