Concept project
Real-time Chat with Next.js and NestJS WebSockets
A concept project with a working demo: a real-time chat where three simulated clients talk through an in-browser Socket.IO-style gateway with rooms, presence, typing indicators, read receipts, acks, an offline outbox and reconnect sync. Tabs sync through a BroadcastChannel. The case study explains the NestJS, Socket.IO and PostgreSQL design behind it.

Overview#
A chat demo that sends a message over a WebSocket and prints it in another window takes an afternoon. A chat that people trust takes much more: messages that never vanish when the train enters a tunnel, the same order on every screen, "seen" ticks that are true, and no duplicates when a phone resends after reconnecting.
I built this concept to show those parts working. The live demo puts three clients side by side, each with its own simulated network, so you can break one connection and watch the system recover.
My role#
Everything: the event protocol, the gateway logic, the client state, the network simulation, the UI and this write-up. It is a concept project, not client work. The people and rooms are fictional.
The problem#
- Order. Ayesha and Ben send at the same moment. Their phones' clocks disagree. Which message is first, and does everyone see the same answer?
- Delivery. A message typed while offline must be sent later, once, in the order it was written.
- Missed events. While a phone is disconnected, the room keeps talking. On reconnect it needs exactly what it missed, not the whole history and not a gap.
- Honest status. Sending, sent, delivered and seen are four different facts, and the UI should only show what the server has confirmed.
- More than one server. As soon as there are two gateway instances, a message sent to one must reach sockets on the other.
Approach & architecture#
- Composerthen
- optimistic bubble (clientId)then
- emit sendthen
- gateway: rate limit + dedupe clientId
- gatewaythen
- assign key (Lamport clock + node)then
- INSERT messagesthen
- ack {key}then
- bubble = sent
- gatewaythen
- room membersthen
- receipt delivered/readthen
- ticks update
- gatewaythen
- Redis adapterthen
- other nodesthen
- their sockets
- network backthen
- connect (token)then
- join room with afterKeythen
- missed messages in key order
- connectthen
- flush outboxthen
- server dedupes by clientIdthen
- acks
The server decides order. Every message gets a key from a Lamport clock plus the node id, padded so plain string comparison sorts them. A node that hears about a message from another node moves its clock forward, so keys never go backwards:
export const makeKey = (clock: number, node: string) => `${String(clock).padStart(10, "0")}:${node}`;
// on a message from another node (the adapter):
this.clock = Math.max(this.clock, parseInt(m.msg.key, 10));Clients sort confirmed messages by key and keep unconfirmed ones at the end in the order they were typed.
Acks plus idempotency. The client shows a message immediately with a random clientId. The server answers the Socket.IO ack with the real key. If the connection drops before the ack arrives, the client simply sends again after reconnecting. The server finds the clientId, stores nothing new, and acks with the original key. That makes "resend everything not acked" safe.
Sync by key, not by time. Each client remembers the last key it saw per room. Rejoining sends join { room, afterKey }, and the gateway answers with messages.filter(m => m.key > afterKey). In the production design that is one indexed query:
CREATE TABLE messages (
room_id text NOT NULL,
key text NOT NULL, -- lamport:node, sorts as text
client_id text NOT NULL,
user_id text NOT NULL,
body text NOT NULL CHECK (length(body) <= 500),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (room_id, key),
UNIQUE (user_id, client_id) -- resends are no-ops
);
CREATE TABLE read_positions (
room_id text NOT NULL,
user_id text NOT NULL,
delivered text NOT NULL DEFAULT '',
read text NOT NULL DEFAULT '',
PRIMARY KEY (room_id, user_id)
);
-- reconnect sync
SELECT * FROM messages WHERE room_id = $1 AND key > $2 ORDER BY key LIMIT 200;Receipts as positions. A receipt is not stored per message. Each user has a "delivered up to" and a "read up to" key per room, and a message is seen by someone when their read key is at or after it. That is one row per user per room instead of one per message per user.
Many tabs, many nodes. Each browser tab runs its own gateway node. A BroadcastChannel carries messages and presence between tabs, which is the role the Socket.IO Redis adapter plays between real server instances.
Key challenges#
- A believable network. Each client has a latency slider, and every event waits that long plus jitter. One link never reorders its own events, like a TCP stream, but two clients can reach the server in either order. Turning the network off drops in-flight events, and the server only notices after a 1.5 s ping timeout, so presence lags the way it does in reality.
- Optimistic UI without lies. A bubble moves from "queued (offline)" to "sending…" to "✓ sent" only on an ack, to "✓✓ delivered" when another client's receipt arrives, and to "seen by Ben" when Ben's read position passes it.
- Typing without flooding. The client sends
typing: trueat most every 2 seconds while you type andfalseafter 2.5 seconds idle or on send. Receivers also expire an indicator after 4 seconds in case the stop event is lost. - Abuse limits. More than 10 messages in 5 seconds gets a
rate_limitedack, shown as a failed bubble with a retry button. Messages over 500 characters are refused.
Tech stack#
- Demo: TypeScript, React and Next.js, with the BroadcastChannel API for multi-tab sync. Gateway and client logic live in
server.tsandclient.tswith no React in them. History is kept inlocalStorage. - Production design: a NestJS WebSocket gateway on the Socket.IO adapter with a JWT guard, the Redis adapter for multiple instances, PostgreSQL for messages and read positions, and a Next.js client component that owns the socket.
Results#
- A working chat with three rooms, presence, typing indicators, delivered and seen receipts, acks, an offline outbox, reconnect sync by key, server-side dedupe, rate limiting and cross-tab chat.
- Every socket event, from connect to sync, is printed in the event log, so you can follow what the gateway did.
- The code under "View source" on the demo page is the code that runs.
There are no latency or concurrency numbers here because this is a concept build without real users or a deployed server.
Try the demo#
Open the real-time chat demo. Things to try:
- Untick network on Ben's pane. Send two messages as Ben and one as Ayesha. Turn Ben's network back on: his messages flush in order and he catches up on Ayesha's.
- Set Carmen's latency to 1500 ms and send from Ayesha. Watch "✓ sent" turn into "seen by Ben" before Carmen's receipt arrives.
- Press Enter eleven times quickly with short messages. The last one is rate-limited; wait a few seconds and press retry.
- Open the page in a second tab and send a message there. It appears in the first tab marked with the other tab's node id.
What I would do next#
- Build the NestJS gateway and Next.js client for real, with the Redis adapter, and an end-to-end test that cuts the network mid-conversation.
- Add cursor-paginated history over REST for scrolling back beyond the last 50 messages.
- Send read receipts and typing across tabs too, and add per-room membership for private rooms.



