What the realtime layer is for
Swappr has two delivery channels for chat:
- REST (send-message, list-messages, mark-read, etc.) — request-response, works everywhere, the canonical history-of-record.
- WebSocket (this page) — Socket.IO bi-directional events for low-latency send, live receipt of new messages, typing indicators, and read receipts.
The two are NOT alternatives — they cooperate. Every WebSocket-delivered event is also reachable via REST (you can poll list-messages and never open a socket); every REST send is also broadcast over WebSocket so any other devices the user is signed in on update live. See REST ↔ WS parity for the contract.
The realtime app is a separate Node process (apps/realtime) deployed alongside the REST API. It scales horizontally via the Socket.IO Redis adapter so a chat:message:new emitted on one instance is delivered to a recipient connected to a different instance.
Connection lifecycle
Step 1 — fetch a ticket
POST /api/v1/auth/socket-ticket requires a Bearer access token and returns a single-use ticket valid for 60 seconds. The endpoint is documented under Auth — socket-ticket. Tickets are stored in Redis with SET ... EX 60 NX so the API never needs to consult Mongo on each socket connect.
The ticket is a credential — it grants access to the user’s chat surface for 60 seconds. Do not log it, embed it in URLs that leak via the Referer header, or store it in localStorage. Treat it like a one-time password.
Step 2 — connect
import { io, type Socket } from 'socket.io-client';
const socket: Socket = io('wss://api.swappr.co.uk', {
auth: { ticket }, // single-use, server-side GETDEL
transports: ['websocket'], // skip long-polling; mobile networks tolerate WS fine
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 500,
reconnectionDelayMax: 5_000,
});
socket.on('connect', () => {
// safe to emit chat:open here
});
socket.on('connect_error', (err) => {
// err.message === 'UNAUTHENTICATED' on ticket failure
});
The middleware in apps/realtime/src/auth.ts calls TicketConsumer.consume(ticket) — a Redis GETDEL in production — which is atomic: the ticket is consumed and deleted in one round-trip, so a replay attack with the same ticket fails. On success it attaches socket.data.userId and auto-joins user:<userId>.
If the ticket is missing, malformed, expired, or already-consumed, the middleware calls next(new Error('UNAUTHENTICATED')) and Socket.IO closes the underlying transport. The client sees a connect_error event.
Step 3 — open conversations
After connect, the socket is in exactly one room: user:<userId> (its personal room, used by future direct notifications). To receive chat:message:new for a conversation, the client MUST emit chat:open { conversationId } for that conversation. The server validates membership via ChatEventsPort.isParticipant and joins the socket to conversation:<id> iff the user is a participant. Non-participants are silently ignored (no error event) — there’s nothing to recover from.
The client should emit one chat:open per conversation it has open in UI (a single conversation screen typically opens one; an inbox screen may open zero or all of them depending on product choices — see reconnect strategy below).
Room model
| Room | Membership | Purpose |
|---|
user:<userId> | Auto-joined on auth | Future targeted notifications (match alerts, system messages). Phase 4 reserves the room but does not yet broadcast into it. |
conversation:<conversationId> | Joined on chat:open after server-side participant check | Per-conversation broadcasts: chat:message:new, chat:message:read, chat:typing. |
Both prefixes live in apps/realtime/src/rooms.ts so they cannot drift between the auth middleware, the message handler, and any future broadcasters.
All sockets connect to the root namespace (/). Phase 4 does not use multiple namespaces; the room model is sufficient for isolation.
Event catalog
Every event payload is documented below with its exact field types (matching apps/realtime/src/events.ts), the ack shape (where applicable), the broadcast target, and the reasons the server may reject.
chat:open (client → server)
Subscribe the socket to a conversation’s broadcast room. The client MUST emit this for each conversation it wants live updates on. A socket that never emits chat:open only sees personal events on user:<userId>.
| Field | Type | Required | Notes | Example |
|---|
conversationId | string | yes | 24-char hex ObjectId. Must reference a conversation the caller participates in (server-side check). | 66400a8f1c2b4d5e6f7a9000 |
Ack: none. The client cannot tell directly whether the join succeeded — but it is safe to emit chat:open redundantly (joining a room you’re already in is a no-op).
Server behaviour:
- Calls
chatEvents.isParticipant(conversationId, userId).
- On
true: socket.join('conversation:<conversationId>').
- On
false or thrown error: silently ignored (no ack, no error event). Probing for valid conversation ids via chat:open is therefore not a useful side channel.
socket.emit('chat:open', { conversationId: '66400a8f1c2b4d5e6f7a9000' });
chat:message:send (client → server, with ack)
Insert a new message into the conversation. This is the WebSocket mirror of POST /chat/conversations/:id/messages; both transports hit the same messageService.sendMessage. See REST ↔ WS parity.
| Field | Type | Required | Notes | Example |
|---|
conversationId | string | yes | 24-char hex ObjectId. | 66400a8f1c2b4d5e6f7a9000 |
clientMessageId | string | yes | 1..64 chars. Idempotency key — see dedup walkthrough. | cm_01HZQ7K3M4N5P6Q7R8S9T0V1W2 |
text | string | yes | 1..4000 chars. Whitespace-only is rejected (bad_text). | Hi! Loved your kitchen. |
Ack shape:
| Field | Type | When | Example |
|---|
ok | boolean | always | true |
messageId | string | undefined | on ok: true | 66400a8f1c2b4d5e6f7aa000 |
sentAt | string | undefined | on ok: true, ISO 8601 UTC | 2026-05-22T14:35:12.001Z |
deduped | boolean | undefined | on ok: true. true iff this was a retry that hit the dedup index — no row inserted, no broadcast. | false |
reason | string | undefined | on ok: false. See error reasons. | bad_text |
Broadcast:
- On
ok: true AND deduped: false: server emits chat:message:new to conversation:<id> (every socket joined to that room, including the sender’s own socket — so a single client with multiple open tabs/devices sees the message land everywhere).
- On
ok: true AND deduped: true: NO broadcast (the receivers already got the original).
- On
ok: false: NO broadcast.
socket.emit(
'chat:message:send',
{
conversationId: '66400a8f1c2b4d5e6f7a9000',
clientMessageId: 'cm_01HZQ7K3M4N5P6Q7R8S9T0V1W2',
text: 'Hi! Loved your kitchen.',
},
(ack: { ok: boolean; messageId?: string; sentAt?: string; deduped?: boolean; reason?: string }) => {
if (!ack.ok) {
// surface ack.reason to the UI
return;
}
if (ack.deduped) {
// already in optimistic UI — nothing more to do
return;
}
// reconcile the optimistic row with messageId / sentAt
},
);
chat:message:new (server → room broadcast)
Emitted to every socket in conversation:<conversationId> when a message has been successfully inserted (deduped: false).
| Field | Type | Notes | Example |
|---|
conversationId | string | 24-char hex ObjectId — the room target. | 66400a8f1c2b4d5e6f7a9000 |
message | object | Full Message object. Same shape as the REST history endpoint returns. | |
socket.on('chat:message:new', (event: { conversationId: string; message: MessageRow }) => {
// append to the local message list, scroll to bottom if user is at bottom
});
The sender’s socket ALSO receives chat:message:new for messages they sent themselves — Socket.IO’s .to(room).emit(...) includes the sender by default. Use the ack to dedup against your optimistic local row, then ignore the matching broadcast OR use the broadcast to reconcile (both patterns work). Do NOT exclude the sender server-side — multi-device users rely on the broadcast to keep tabs in sync.
chat:read (client → server, with ack)
Mark messages read up to a cursor. Mirror of POST /chat/conversations/:id/read.
| Field | Type | Required | Notes | Example |
|---|
conversationId | string | yes | 24-char hex ObjectId. | 66400a8f1c2b4d5e6f7a9000 |
upToMessageId | string | yes | 24-char hex ObjectId of the last-read message. Inclusive cursor. | 66400a8f1c2b4d5e6f7aa001 |
Ack shape:
| Field | Type | When | Example |
|---|
ok | boolean | always | true |
newlyReceiptedCount | integer | undefined | on ok: true. Zero on no-op. | 1 |
reason | string | undefined | on ok: false. | bad_id |
Broadcast: on ok: true, server emits chat:message:read to conversation:<id> (yes, including the caller — same multi-device reasoning as chat:message:new).
socket.emit(
'chat:read',
{ conversationId, upToMessageId },
(ack: { ok: boolean; newlyReceiptedCount?: number; reason?: string }) => { /* ... */ },
);
chat:message:read (server → room broadcast)
Emitted after a successful chat:read. The sender’s UI uses this to update its read-receipt indicators in real time.
| Field | Type | Notes | Example |
|---|
conversationId | string | 24-char hex ObjectId — the room target. | 66400a8f1c2b4d5e6f7a9000 |
byUserId | string | 24-char hex ObjectId of the user who read up to the cursor. | 66400a8f1c2b4d5e6f7a8b00 |
upToMessageId | string | 24-char hex ObjectId of the cursor message. | 66400a8f1c2b4d5e6f7aa001 |
at | string | ISO 8601 UTC of when the server processed the mark-read. Comes from the injected Clock, not new Date(). | 2026-05-22T14:36:01.022Z |
chat:typing:start / chat:typing:stop (client → server)
Debounced typing notifications. Phase 4 does NOT validate that the caller is a participant of the conversation — typing is purely cosmetic and the cost of a stricter check exceeds the abuse surface (the worst case is a non-participant sending a start event to a room they aren’t in, which is a no-op since the broadcast only reaches sockets in that room).
| Field | Type | Required | Notes | Example |
|---|
conversationId | string | yes | 24-char hex ObjectId. | 66400a8f1c2b4d5e6f7a9000 |
Ack: none — fire-and-forget.
Broadcast: server emits chat:typing with phase: 'start' or phase: 'stop' to conversation:<id>, excluding the sender’s own socket (via socket.to(room).emit(...)). The typist doesn’t need to see their own typing indicator.
The client convention is:
- Emit
chat:typing:start when the user starts typing (e.g. first keystroke).
- Emit
chat:typing:stop after 3s of inactivity, when the user sends, OR when the screen loses focus.
- Do not spam
chat:typing:start on every keystroke — the server forwards each one and the recipient’s UI will flicker.
socket.emit('chat:typing:start', { conversationId });
// ...3s later if still idle...
socket.emit('chat:typing:stop', { conversationId });
chat:typing (server → room broadcast)
| Field | Type | Notes | Example |
|---|
conversationId | string | 24-char hex ObjectId. | 66400a8f1c2b4d5e6f7a9000 |
userId | string | 24-char hex ObjectId of the typist. | 66400a8f1c2b4d5e6f7a8b00 |
phase | enum | start or stop. | start |
at | string | ISO 8601 UTC from the injected Clock. | 2026-05-22T14:36:02.500Z |
socket.on('chat:typing', (event: { conversationId: string; userId: string; phase: 'start' | 'stop'; at: string }) => {
// update typing indicator
});
At-least-once delivery and dedup
The realtime layer is at-least-once, not exactly-once. A client retry on a flaky network can produce duplicate chat:message:send emits with the same clientMessageId. The server’s job is to make those retries safe — the contract is:
- A
(senderId, clientMessageId) unique index on messages makes the second insert E11000.
- The service’s
findByClientId fast-path returns the existing row without a re-insert.
- The race-recovery catch on E11000 re-reads after a duplicate-key error.
- The ack reports
deduped: true so the client knows this was a retry, not a fresh send.
- No
chat:message:new is broadcast on the dedup path — the original send already broadcast it, and replaying would cause UIs to render the same message twice.
Dedup race diagram
Two parallel retries firing at once can both miss the findByClientId fast path. The catch on E11000 covers that case: the loser of the insert race re-reads and returns the winner’s row with deduped: true. No combination of retries can ever produce two messages rows or two chat:message:new broadcasts.
Client requirements
For the contract to hold, the client MUST:
- Generate
clientMessageId BEFORE the first attempt (ULID or UUID recommended).
- Reuse the SAME
clientMessageId on every retry of the same logical message.
- Treat
ack.deduped === true as success — the message landed (on the first attempt) and the broadcast already fired (other recipients have it).
- NOT increment the optimistic row twice: when the ack arrives with
deduped: true, the local optimistic UI is already in the correct state.
Full send-flow sequence
REST and WS parity
BACKEND_PLAN §8.4 mandates that REST POST /chat/conversations/:id/messages and the WebSocket chat:message:send event hit the same messageService.sendMessage method. Consequences:
| Concern | REST | WebSocket |
|---|
| Auth | Bearer accessToken | Single-use socket ticket → socket.data.userId |
| Payload validation | Zod (SendMessageRequestSchema) at controller | Hand-written guards at socket handler |
| Service-layer gates | requireSendable (participant, status==ACTIVE, no block) | Same requireSendable |
| Dedup | Same findByClientId + E11000 catch | Same |
| Persistence | Same messages.insert + conversations.updateLastMessage | Same |
| Broadcast | Emits chat:message:new via the Socket.IO instance the API process holds | Emits chat:message:new directly |
| Push fan-out (Phase 5) | Same notification.push enqueue | Same |
Take-aways:
- A client can send via REST and receive the broadcast via WebSocket — works.
- A client can send via WebSocket and read history via REST — works.
- Error codes are the same: a REST
403 CONVERSATION_BLOCKED corresponds to a WS ack with reason: 'CONVERSATION_BLOCKED'.
- Mobile clients with intermittent connectivity should send via REST (which queues nicely at the OS layer) and use WebSocket purely for receiving live broadcasts. Mobile clients with a stable connection can send via either.
Ack error reasons
The server returns { ok: false, reason: '...' } on failure. Two categories:
Payload validation (server-side guard rejection — no service call)
These are returned synchronously from the socket handler before any service is touched. They indicate a client bug — fix the payload, don’t retry.
reason | When |
|---|
bad_conversationId | conversationId is missing or not a 24-char hex ObjectId. |
bad_clientMessageId | clientMessageId is missing, empty, or > 64 chars. |
bad_text | text is missing, empty, or > 4000 chars. |
bad_id | chat:read payload had a malformed conversationId or upToMessageId. |
Service-level rejection (passed through from AppError.code)
These mirror the REST error catalog exactly — same conditions, same recovery paths. See Errors for the full catalog.
reason | REST equivalent | When |
|---|
NOT_FOUND | 404 NOT_FOUND | Conversation doesn’t exist. |
FORBIDDEN | 403 FORBIDDEN | Caller is not a participant. |
CONVERSATION_BLOCKED | 403 CONVERSATION_BLOCKED | Conversation status ≠ ACTIVE, OR a block exists in either direction. |
VALIDATION_FAILED | 400 VALIDATION_FAILED | Service-level validation failed (e.g. whitespace-only text after the wire-level guard). |
internal_error | 500 INTERNAL_ERROR | An unexpected exception bubbled out of the service. The server logs the cause; the client should retry with exponential backoff. |
The client should surface ack failures to the UI like any other error — there is no “retry the socket event automatically” pattern except for the clientMessageId dedup case described above.
Reconnect strategy
Sockets disconnect — flaky mobile networks, OS suspension, server deploys. The client MUST:
-
Treat the ticket as exhausted on every reconnect. Tickets are single-use; the original was consumed at first connect. The reconnect path MUST fetch a fresh ticket via
POST /api/v1/auth/socket-ticket before re-emitting io({ auth: { ticket } }). Trying to reconnect with the old ticket fails with connect_error('UNAUTHENTICATED').
-
Re-emit
chat:open for every conversation it had open before the disconnect. Rooms are NOT remembered across socket lifecycles — a fresh socket starts in only user:<userId>. The client should keep a local list of “currently open conversation ids” and emit chat:open for each one after connect.
-
Re-fetch history via REST. During the disconnect window, messages may have been sent that the client never saw via
chat:message:new. After reconnect, the client should call list-messages for each open conversation to catch up. The REST endpoint is the canonical history-of-record; the socket is the live channel.
The Socket.IO client’s built-in reconnection is fine to use, but you MUST plug in a reconnect_attempt handler that fetches a fresh ticket before each attempt:
socket.on('reconnect_attempt', async () => {
const fresh = await fetchSocketTicket(); // POST /auth/socket-ticket
socket.auth = { ticket: fresh.ticket };
});
socket.on('connect', async () => {
for (const conversationId of openConversationIds) {
socket.emit('chat:open', { conversationId });
}
for (const conversationId of openConversationIds) {
const fresh = await fetch(`/api/v1/chat/conversations/${conversationId}/messages?limit=50`);
// merge with local cache
}
});
Out-of-scope (intentionally)
Phase 4 deliberately does NOT ship the following — clients should not depend on them existing.
- Per-conversation rate limiting on the socket transport. The REST send is documented to be subject to 60/min/user; the socket transport piggybacks on the same
messageService but the realtime layer does not yet wrap it in a rate limiter. Coming in Phase 5.
- Delivered receipts (
chat:message:delivered). The data model has a deliveredTo[] field but Phase 4 does not yet populate it — the field is always []. Receipts are emitted in Phase 5 once the push pipeline is wired (an APNs/FCM “delivered” callback flips it).
- Edit / delete events (
chat:message:edited, chat:message:deleted). The data model has editedAt and deletedAt but no API exposes them yet.
- Presence (
user:online / user:offline). Socket.IO knows when a socket connects/disconnects but no broadcast fires today. Phase 5 may add this for the inbox UI.
- Multiple namespaces. The MVP is single-tenant and uses the root namespace exclusively.
See also