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

- Author: Sami Ullah (https://samiullah.tech/about)
- Type: Concept project
- Role: Full build
- Stack: TypeScript, React.js, Next.js, BroadcastChannel API
- Year: 2026
- Updated: 2026-11-13
- Canonical: https://samiullah.tech/work/realtime-chat

## 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](/lab/realtime-chat) 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

```flow
# Send a message
Composer -> optimistic bubble (clientId) -> emit send -> gateway: rate limit + dedupe clientId
gateway -> assign key (Lamport clock + node) -> INSERT messages -> ack {key} -> bubble = sent
gateway -> room members -> receipt delivered/read -> ticks update
gateway -> Redis adapter -> other nodes -> their sockets
```

```flow
# Reconnect
network back -> connect (token) -> join room with afterKey -> missed messages in key order
connect -> flush outbox -> server dedupes by clientId -> 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:

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

```sql
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: true` at most every 2 seconds while you type and `false` after 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_limited` ack, 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.ts` and `client.ts` with no React in them. History is kept in `localStorage`.
- **Production design:** a [NestJS](/skills/nestjs) WebSocket gateway on the Socket.IO adapter with a JWT guard, the Redis adapter for multiple instances, [PostgreSQL](/skills/postgresql) for messages and read positions, and a [Next.js](/skills/nextjs) 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](/lab/realtime-chat). Things to try:

1. 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.
2. Set Carmen's latency to 1500 ms and send from Ayesha. Watch "✓ sent" turn into "seen by Ben" before Carmen's receipt arrives.
3. Press Enter eleven times quickly with short messages. The last one is rate-limited; wait a few seconds and press **retry**.
4. 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.
