Skip to content
~sami-ullah

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.

Screenshot of the Real-time Chat with Next.js and NestJS WebSockets demo

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#

◆ Send a message
  1. Composerthen
  2. optimistic bubble (clientId)then
  3. emit sendthen
  4. gateway: rate limit + dedupe clientId
  1. gatewaythen
  2. assign key (Lamport clock + node)then
  3. INSERT messagesthen
  4. ack {key}then
  5. bubble = sent
  1. gatewaythen
  2. room membersthen
  3. receipt delivered/readthen
  4. ticks update
  1. gatewaythen
  2. Redis adapterthen
  3. other nodesthen
  4. their sockets
◆ Reconnect
  1. network backthen
  2. connect (token)then
  3. join room with afterKeythen
  4. missed messages in key order
  1. connectthen
  2. flush outboxthen
  3. server dedupes by clientIdthen
  4. 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 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:

  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.
Full screenshot of the Real-time Chat with Next.js and NestJS WebSockets demo running in the browser
Screenshot of the live demo with its sample data. Click to open it.
▶ live demoChat as three people, take one offline, send messages, reconnect and watch the outbox flush and missed messages sync.

Skills used

  • Screenshot of Clinic Booking Systemlive demo

    Concept project

    Clinic Booking System

    Book a slot in your time zone, race two bookings for one slot, then drag appointments in the admin calendar and watch overlaps fail.

    TypeScriptReact.jsNext.jszod
    ▶ Open demo
  • Screenshot of Salon Booking Marketplace — Multi-Salon Platform

    Own product

    Salon Booking Marketplace — Multi-Salon Platform

    A multi-salon booking marketplace I designed and built for Pakistan. Clients find nearby salons on a map, choose a barber, service and time, and book at the salon or as a home visit. Salons run staff, schedules and earnings from their own dashboard, and the platform collects a commission per completed booking through daily settlements.

    Next.jsReact.jsTypeScriptTailwind CSS

#nextjs6 min

JSON-LD Structured Data in Next.js 15

How to add JSON-LD structured data in Next.js 15: a safe script component, typed schema-dts graphs with stable @id values, FAQ rules and how to test it.

read →