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

RoomMembershipPurpose
user:<userId>Auto-joined on authFuture 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 checkPer-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>.
FieldTypeRequiredNotesExample
conversationIdstringyes24-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.
FieldTypeRequiredNotesExample
conversationIdstringyes24-char hex ObjectId.66400a8f1c2b4d5e6f7a9000
clientMessageIdstringyes1..64 chars. Idempotency key — see dedup walkthrough.cm_01HZQ7K3M4N5P6Q7R8S9T0V1W2
textstringyes1..4000 chars. Whitespace-only is rejected (bad_text).Hi! Loved your kitchen.
Ack shape:
FieldTypeWhenExample
okbooleanalwaystrue
messageIdstring | undefinedon ok: true66400a8f1c2b4d5e6f7aa000
sentAtstring | undefinedon ok: true, ISO 8601 UTC2026-05-22T14:35:12.001Z
dedupedboolean | undefinedon ok: true. true iff this was a retry that hit the dedup index — no row inserted, no broadcast.false
reasonstring | undefinedon 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).
FieldTypeNotesExample
conversationIdstring24-char hex ObjectId — the room target.66400a8f1c2b4d5e6f7a9000
messageobjectFull 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.
FieldTypeRequiredNotesExample
conversationIdstringyes24-char hex ObjectId.66400a8f1c2b4d5e6f7a9000
upToMessageIdstringyes24-char hex ObjectId of the last-read message. Inclusive cursor.66400a8f1c2b4d5e6f7aa001
Ack shape:
FieldTypeWhenExample
okbooleanalwaystrue
newlyReceiptedCountinteger | undefinedon ok: true. Zero on no-op.1
reasonstring | undefinedon 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.
FieldTypeNotesExample
conversationIdstring24-char hex ObjectId — the room target.66400a8f1c2b4d5e6f7a9000
byUserIdstring24-char hex ObjectId of the user who read up to the cursor.66400a8f1c2b4d5e6f7a8b00
upToMessageIdstring24-char hex ObjectId of the cursor message.66400a8f1c2b4d5e6f7aa001
atstringISO 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).
FieldTypeRequiredNotesExample
conversationIdstringyes24-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)

FieldTypeNotesExample
conversationIdstring24-char hex ObjectId.66400a8f1c2b4d5e6f7a9000
userIdstring24-char hex ObjectId of the typist.66400a8f1c2b4d5e6f7a8b00
phaseenumstart or stop.start
atstringISO 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:
  1. Generate clientMessageId BEFORE the first attempt (ULID or UUID recommended).
  2. Reuse the SAME clientMessageId on every retry of the same logical message.
  3. Treat ack.deduped === true as success — the message landed (on the first attempt) and the broadcast already fired (other recipients have it).
  4. 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:
ConcernRESTWebSocket
AuthBearer accessTokenSingle-use socket ticket → socket.data.userId
Payload validationZod (SendMessageRequestSchema) at controllerHand-written guards at socket handler
Service-layer gatesrequireSendable (participant, status==ACTIVE, no block)Same requireSendable
DedupSame findByClientId + E11000 catchSame
PersistenceSame messages.insert + conversations.updateLastMessageSame
BroadcastEmits chat:message:new via the Socket.IO instance the API process holdsEmits chat:message:new directly
Push fan-out (Phase 5)Same notification.push enqueueSame
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.
reasonWhen
bad_conversationIdconversationId is missing or not a 24-char hex ObjectId.
bad_clientMessageIdclientMessageId is missing, empty, or > 64 chars.
bad_texttext is missing, empty, or > 4000 chars.
bad_idchat: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.
reasonREST equivalentWhen
NOT_FOUND404 NOT_FOUNDConversation doesn’t exist.
FORBIDDEN403 FORBIDDENCaller is not a participant.
CONVERSATION_BLOCKED403 CONVERSATION_BLOCKEDConversation status ≠ ACTIVE, OR a block exists in either direction.
VALIDATION_FAILED400 VALIDATION_FAILEDService-level validation failed (e.g. whitespace-only text after the wire-level guard).
internal_error500 INTERNAL_ERRORAn 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:
  1. 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').
  2. 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.
  3. 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