# Delivery Guarantees: Acks, Retries and Read Receipts

> Make Relay reliable on flaky networks with a client outbox, safe retries, catch-up after reconnects, and delivered and read receipts with unread counts.

- Author: Syed Wajeeh Haider
- Published: Sep 28, 2026
- Series: Real-Time Chat at Scale, part 5 of 10
- Category: Reliability
- Tags: Reliability, Socket.IO, Messaging, Offline
- Reading time: 7 min
- URL: https://syedwajeehhaider.com/message-delivery-guarantees

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.emit` without 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.

*client/src/outbox.ts*

```ts
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.

> **Persist the outbox:** Keep pending entries in IndexedDB on the web or SQLite/MMKV on mobile. If the user sends a message in a tunnel and closes the app, the outbox is replayed on next launch. Because retries are idempotent, replaying an entry that actually made it through is harmless.

## 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.

*server/src/messages.ts: messages since a cursor*

```ts
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 };
}
```

*client: rejoin, then catch up*

```ts
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.

```ts
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**.

*Moving the read pointer forward only*

```ts
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.

```sql
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`.
- `connectionStateRecovery` is 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.
