---
title: Real-time Layer
tags: [architecture, realtime, websocket, socket.io]
created: 2026-05-23
---

# Real-time Layer

Socket.IO 4.8 is the single transport for all server-pushed updates: chat messages, notifications, payment status, offer arrivals, dispute updates.

> [!info]
> Backend handler: `backend/src/infrastructure/socket/socketService.ts`. Frontend context: `frontend/src/contexts/socket-context.tsx` consumed via `useSocket()`.

---

## 1. Connection lifecycle

```mermaid
sequenceDiagram
    actor U as Browser
    participant FE as React (SocketProvider)
    participant BE as Socket.IO Server
    U->>FE: Login completes (JWT stored)
    FE->>BE: io.connect(url, { auth: { token } })
    BE->>BE: verifyJwt(token) → req.user
    BE-->>FE: "connect" + socket.id
    FE->>BE: emit "join-user-room", userId
    BE-->>FE: ack
    Note over BE,FE: Long-lived connection. Auto-reconnect handles drops.
    BE-->>FE: emit "notification:received" (when triggered)
    FE->>FE: update React Query cache / show snackbar
```

On the client:
- Socket is **created lazily** after auth state is known.
- Connection auto-recovers on disconnect (Socket.IO default exponential backoff).
- The provider exposes `connected: boolean` so UI can show a "reconnecting…" indicator.

On the server:
- The `connection` handler verifies the JWT from `socket.handshake.auth.token`.
- If invalid → `socket.disconnect(true)`.
- If valid → `socket.data.user = decoded` is set for use in subsequent handlers.

---

## 2. Room model

Rooms are joined explicitly by the client via emitter events. Names use a `prefix-{id}` convention so socket addressing matches the canonical entity ID.

| Room | Joined by | Purpose |
|---|---|---|
| `user-{userId}` | every authenticated socket | User-targeted events (notifications, payment updates for them) |
| `seller-{userId}` | sellers on login | Seller-targeted events (new offer requests in their category) |
| `sellers` | all sellers | Broadcast to all sellers (e.g., new public request) |
| `buyer-{userId}` | buyers on login | Buyer-targeted events |
| `buyers` | all buyers | Broadcast to all buyers |
| `request-{requestId}` | participants of a request (buyer + accepted seller) | Per-request updates (status, offer arrivals) |
| `chat-{chatId}` | conversation participants | Live chat messages, typing, read receipts |
| `admin` | admin users | Mod-only events (new dispute opened, suspicious activity) |

Client-side join/leave emitters (defined in `socketService.ts`):

| Emit | Args | Effect |
|---|---|---|
| `join-user-room` | `userId` | Adds socket to `user-{userId}` |
| `join-request-room` | `requestId` | Adds to `request-{requestId}` |
| `leave-request-room` | `requestId` | Removes from `request-{requestId}` |
| `join-seller-room` | `sellerId` | Adds to `seller-{sellerId}` and `sellers` |
| `leave-seller-room` | `sellerId` | Removes from both |
| `join-buyer-room` | `buyerId` | Adds to `buyer-{buyerId}` and `buyers` |
| `leave-buyer-room` | `buyerId` | Removes from both |
| `join-chat-room` | `chatId` | Adds to `chat-{chatId}` |

---

## 3. Emit helpers (server-side)

The socket service exposes typed emitters used across all services so individual modules never touch `io` directly:

```ts
// src/infrastructure/socket/socketService.ts
export function emitToRoom(room: string, event: string, payload: unknown): void;
export function emitToUser(userId: string, event: string, payload: unknown): void;
export function emitToSellers(event: string, payload: unknown): void;
export function emitToBuyers(event: string, payload: unknown): void;
export function emitGlobalEvent(event: string, payload: unknown): void;
```

This indirection makes it trivial to:
- Add per-event logging
- Throttle / batch in the future
- Swap to a Redis pub/sub adapter when scaling out

---

## 4. Event catalog

### 4.1 Notifications

| Event | Payload | Emitted by | Rooms |
|---|---|---|---|
| `notification:received` | `Notification` doc | NotificationService | `user-{recipientId}` |
| `notification:read` | `{ id, readAt }` | NotificationService.markRead | `user-{id}` |

### 4.2 Marketplace

| Event | Payload | Emitted by | Rooms |
|---|---|---|---|
| `request:created` | `PurchaseRequest` | PurchaseRequestService.create | `sellers` (or category-scoped sellers in future) |
| `request:offer-received` | `{ requestId, offer: SellerOffer }` | SellerOfferService.create | `user-{buyerId}`, `request-{requestId}` |
| `request:status-updated` | `{ requestId, status }` | various | `request-{requestId}` |
| `offer:negotiation-update` | `{ offerId, message }` | SellerOfferService.counter | `request-{requestId}` |
| `offer:accepted` | `{ offerId }` | SellerOfferService.accept | `seller-{sellerId}`, `user-{buyerId}` |

### 4.3 Payment

| Event | Payload | Emitted by |
|---|---|---|
| `payment:status-updated` | `{ paymentId, status, providerPaymentId }` | shkeeperWebhook → PaymentService |
| `payment:created` | `Payment` | PaymentService.createPayInIntent |
| `payment:completed` | `{ paymentId }` | PaymentService on webhook completion |
| `payout:created` | `Payout` | shkeeperPayoutService |
| `payout:completed` | `{ payoutId, txHash }` | payout polling / webhook |

### 4.4 Chat

| Event | Payload | Rooms |
|---|---|---|
| `chat:new-message` | `Message` | `chat-{chatId}` and `user-{participantId}` (for badge) |
| `chat:message-read` | `{ chatId, messageId, userId, readAt }` | `chat-{chatId}` |
| `chat:typing` | `{ chatId, userId }` | `chat-{chatId}` |
| `chat:stopped-typing` | `{ chatId, userId }` | `chat-{chatId}` |

### 4.5 Dispute

| Event | Payload | Rooms |
|---|---|---|
| `dispute:opened` | `Dispute` | `request-{requestId}`, `admin` |
| `dispute:evidence-added` | `{ disputeId, evidence }` | `request-{requestId}` |
| `dispute:resolved` | `{ disputeId, resolution }` | `request-{requestId}`, `user-{buyerId}`, `user-{sellerId}` |

### 4.6 System

| Event | Payload | Rooms |
|---|---|---|
| `system:maintenance` | `{ message, startsAt }` | global broadcast |
| `system:announcement` | `{ message }` | global broadcast |

---

## 5. Client-side hooks

Higher-level React hooks consume the socket and integrate with React Query so UI stays consistent:

| Hook | Listens for | Effect |
|---|---|---|
| `useChatSocket(chatId)` | `chat:new-message`, `chat:typing` | Appends to chat query cache; updates typing state |
| `useConversations()` | `chat:new-message` (any chat) | Bumps conversation in list; updates unread count |
| `useNotifications()` | `notification:received` | Prepends to notification list; shows snackbar |
| `usePurchaseRequests()` | `request:status-updated`, `request:offer-received` | Invalidates `['requests']` cache |
| `useMarketplaceSocket()` | `request:created` (seller side) | Bumps seller feed |
| `useUnifiedRealTime()` | multi | Aggregates the above for the dashboard overview |

Pattern: each hook subscribes inside a `useEffect` and unsubscribes in cleanup. Use `socket.off(event, handler)` to avoid handler leaks on re-render.

---

## 6. Authentication & authorization on sockets

- **Connection-time auth** — JWT verified in handshake; invalid token → disconnect.
- **Per-event auth** — Critical handlers re-check role from `socket.data.user.role`. Example: `admin` room membership only added for admins.
- **Tampering** — `userId` arguments in `join-*-room` events are **NOT trusted blindly**; the server must verify `socket.data.user.id === userId` before adding to the room (cite: needs verification in `socketService.ts`).

> [!warning]
> If `join-user-room` accepts any userId from the client, malicious users could subscribe to other users' notifications. Always cross-check with `socket.data.user.id`.

---

## 7. Reconnection & buffering

Socket.IO buffers up to N events per disconnected client by default — but **the server does not replay missed events after a long disconnect**. Frontend strategy:

1. On reconnect, the SocketProvider re-fires `join-*-room` for the user's current state.
2. Critical hooks (notifications, chat) call `refetch()` on reconnect to backfill from REST.
3. Last-event-id pattern is **not implemented**; consider adding for chat reliability.

---

## 8. Scaling to multiple backend nodes

Socket.IO requires a shared bus when running >1 backend instance. Recommended adapter:

```ts
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const pub = createClient({ url: config.redisUri, password: config.redisPassword });
const sub = pub.duplicate();
await Promise.all([pub.connect(), sub.connect()]);
io.adapter(createAdapter(pub, sub));
```

Without this adapter, emits from node A never reach sockets connected to node B.

Sticky sessions on the load balancer are also required so a given client always lands on the same node (otherwise the handshake / connection upgrade fails).

---

## 9. Testing

- Backend tests (`backend/__tests__/`) mock the socket emitter via the indirection layer — no real socket server in unit tests.
- Manual smoke test: open two browser tabs as buyer + seller, accept an offer, watch both receive `offer:accepted` instantly.

---

## Related

- [[Backend Architecture]] · [[Frontend Architecture]]
- [[Chat Flow]] · [[Notification Flow]] · [[Escrow Flow]] · [[Dispute Flow]]
- [[Security Architecture]] — socket auth concerns
- [[Socket Events]] — full event reference (developer-facing API doc)
