Skip to content

Building a Real-Time App

This is the capstone. Every earlier module added one capability; here we assemble them into a single, small, real-time service — a room-based chat with notifications — and run it on a genuine Node ws server. The goal is not new theory. It is to watch the pieces you already know snap together into something that actually holds up: auth on connect, rooms for routing, heartbeats to detect dead links, and a wire shape that survives reconnect.

A real-time service is a pipeline that each connection flows through. When a socket arrives we authenticate it, attach per-connection state, let it join rooms, route its messages, and keep it alive with heartbeats until it leaves:

flowchart TD
  open["Client connects<br/>(token in query / first message)"] --> auth{"Token valid?"}
  auth -- "No" --> reject["close(4401)<br/>unauthorized"]
  auth -- "Yes" --> attach["Attach state:<br/>userId, rooms, isAlive"]
  attach --> ready["Send 'ready'<br/>client can subscribe"]
  ready --> route["Route messages:<br/>join / leave / chat"]
  route --> beat["Heartbeat:<br/>ping every 30s, expect pong"]
  beat -- "no pong" --> dead["terminate dead socket"]
  route -- "client gone" --> clean["close -> leave all rooms"]
The lifecycle of one connection in the service

Each box maps to a module you have already done: auth is security, per-connection state and rooms are server design, heartbeats are the connection lifecycle, and the message envelope is message design. The capstone is just the wiring.

  • Auth on connect. Reject unauthenticated sockets immediately with a custom close code so the client knows not to blindly retry. Here we read a simple token from the upgrade request URL; in production it would be a verified session or JWT (see the security module).
  • Rooms. Each connection carries a Set of rooms it has joined. A chat message is delivered only to other sockets in the same room — the pub/sub pattern from earlier in this module, scoped to a group.
  • Heartbeats. TCP can go silent without an error event — a laptop sleeps, a phone loses signal. A periodic ping with an expected pong is how the server learns a connection is actually dead and reclaims it, instead of broadcasting forever into a void.
  • Reconnect-friendliness. The server cannot reconnect for the client, but it can make reconnection cheap: a clean, idempotent join so a returning client just re-announces its rooms, and stable close codes so the client knows whether to retry (transient) or stop (auth failure).

Keep it boringly explicit — a type tag and the fields each type needs:

// client -> server
type ClientMessage =
| { type: 'join'; room: string }
| { type: 'leave'; room: string }
| { type: 'chat'; room: string; text: string };
// server -> client
type ServerMessage =
| { type: 'ready'; userId: string }
| { type: 'joined'; room: string }
| { type: 'chat'; room: string; from: string; text: string }
| { type: 'error'; message: string };

Below is the body of the connection handler for a real ws server — ws (this socket), wss (the server, for room broadcast), and req (the upgrade request) are already in scope. It authenticates from a ?token= query parameter, attaches per-connection state, handles join/leave/chat with room-scoped broadcast, and runs a per-socket heartbeat. Open it in StackBlitz, run npm start, then connect a client (add ?token=alice to the URL) and watch a join, a room-scoped chat, and the heartbeat in the server log.

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

To exercise rooms properly, connect two clients with different tokens (e.g. ?token=alice and ?token=bob), have both join the same room, and watch a chat from one appear on the other but never echo back to the sender. Disconnect one and the server’s close handler clears its rooms — no ghost membership left behind.

Step back and notice what this small file is: a complete, defensible real-time service. It refuses anonymous sockets, routes by room instead of blasting everyone, survives clients that vanish without a close, and speaks a typed protocol a reconnecting client can resume against. Every one of those properties came from a separate module — and assembling them is exactly the skill this course set out to teach. From here, scaling it across instances is the pub/sub fan-out you already met; everything else is product.

Why does the server reject an unauthenticated socket with a specific close code like 4401?
What problem do heartbeats solve in this service?
How is a chat message routed in the demo server?