Project: Real-Time Chat App
A chat app is the canonical real-time project: it forces you to think in events rather than request/response cycles, manage long-lived connections, and broadcast state to many clients at once. In this project you’ll build a multi-room chat server with Socket.IO on top of Node.js, complete with rooms, presence tracking, typing indicators, and message persistence. Socket.IO sits on top of WebSockets (falling back to HTTP long-polling when needed) and gives you reconnection, rooms, and acknowledgements for free, so you can focus on the chat logic itself. Work through the build steps at the end to assemble it incrementally.
How real-time differs from REST
A REST endpoint answers one request and forgets you. A Socket.IO connection stays open: the server can push to the client at any moment, and the client can emit named events back. Both sides communicate through small, named messages rather than URLs and verbs.
| Concept | REST | Socket.IO |
|---|---|---|
| Unit of work | Request → response | Named event (socket.emit) |
| Direction | Client pulls | Server and client push |
| Connection | Short-lived per request | One long-lived, auto-reconnecting |
| Grouping | Query params / paths | Rooms (socket.join) |
| Best fit | CRUD, fetching data | Chat, presence, live dashboards |
Project setup
Use ES modules ("type": "module" in package.json). Socket.IO needs an HTTP server to attach to; pair it with Express to serve the client and any REST routes.
npm init -y
npm install express socket.io
npm install --save-dev nodemon
// server.js
import { createServer } from "node:http";
import express from "express";
import { Server } from "socket.io";
const app = express();
app.use(express.static("public"));
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: { origin: process.env.CLIENT_ORIGIN ?? "*" },
});
httpServer.listen(3000, () => console.log("Chat on http://localhost:3000"));
Connections and identity
Every browser tab that connects gets a socket with a unique socket.id. Read the user’s chosen name from the connection handshake so you can attach it to their messages and presence. The handshake auth object is sent by the client when it connects.
io.use((socket, next) => {
const name = socket.handshake.auth?.username?.trim();
if (!name) return next(new Error("Username required"));
socket.data.username = name;
next();
});
io.on("connection", (socket) => {
console.log(`${socket.data.username} connected (${socket.id})`);
socket.on("disconnect", () => {
console.log(`${socket.data.username} disconnected`);
});
});
The io.use() middleware runs once per connection — it’s the right place for authentication, rejecting bad clients before any chat events fire.
Rooms and broadcasting
A room is just a named bucket of sockets. Joining one is a single call, and emitting to a room reaches every member. Use socket.to(room) to reach everyone except the sender, and io.to(room) to reach everyone including them.
io.on("connection", (socket) => {
socket.on("room:join", async (room) => {
socket.join(room);
socket.data.room = room;
// Send recent history just to this socket.
socket.emit("history", await loadMessages(room));
// Tell the rest of the room someone arrived.
socket.to(room).emit("system", `${socket.data.username} joined`);
io.to(room).emit("presence", await listMembers(io, room));
});
socket.on("message:send", async (text) => {
const room = socket.data.room;
if (!room || !text?.trim()) return;
const message = {
id: crypto.randomUUID(),
user: socket.data.username,
text: text.trim(),
at: Date.now(),
};
await saveMessage(room, message);
io.to(room).emit("message:new", message); // includes the sender
});
});
Gotcha: Don’t loop over
io.sockets.socketsto broadcast manually — rooms already do this efficiently, and they work across multiple server instances when you add the Redis adapter.
Online presence
Presence is the list of who’s currently in a room. Socket.IO tracks room membership for you, so you can derive presence on demand by reading the socket IDs in a room and mapping them to usernames.
async function listMembers(io, room) {
const sockets = await io.in(room).fetchSockets();
return sockets.map((s) => s.data.username);
}
Re-emit presence whenever someone joins, and also on disconnect. On disconnect the socket has already left its rooms, so capture the room from socket.data first.
socket.on("disconnect", async () => {
const room = socket.data.room;
if (!room) return;
socket.to(room).emit("system", `${socket.data.username} left`);
io.to(room).emit("presence", await listMembers(io, room));
});
Typing indicators
Typing indicators are ephemeral — never persist them. Emit a lightweight event to the room (excluding the sender) when typing starts, and another when it stops. Debounce on the client so you aren’t flooding the wire on every keystroke.
socket.on("typing:start", () => {
socket.to(socket.data.room).emit("typing", {
user: socket.data.username,
typing: true,
});
});
socket.on("typing:stop", () => {
socket.to(socket.data.room).emit("typing", {
user: socket.data.username,
typing: false,
});
});
Persisting messages
So history survives restarts and late joiners can catch up, store messages in a database. The two helpers used above wrap whatever store you choose — here a minimal Postgres-backed version using pg.
import { query } from "./db.js";
export async function saveMessage(room, m) {
await query(
"INSERT INTO messages (id, room, username, text, created_at) VALUES ($1, $2, $3, $4, to_timestamp($5 / 1000.0))",
[m.id, room, m.user, m.text, m.at],
);
}
export async function loadMessages(room, limit = 50) {
const { rows } = await query(
"SELECT id, username AS user, text, EXTRACT(EPOCH FROM created_at) * 1000 AS at FROM messages WHERE room = $1 ORDER BY created_at DESC LIMIT $2",
[room, limit],
);
return rows.reverse(); // oldest first for display
}
When a client connects it joins a room, immediately receives history, and then live message:new events from that point on:
Output:
Alice connected (n3Kf...)
Bob connected (q7Lp...)
[general] Alice: morning all
[general] Bob: hey Alice
Bob disconnected
Build steps
- Echo server — stand up Express + Socket.IO, log every
connectionanddisconnect. - Identity — read
usernamefrom the handshake inio.use()middleware. - Rooms — implement
room:joinand broadcast asystemjoin message. - Messaging — handle
message:send, broadcastmessage:newto the room. - Presence — emit the member list on join and disconnect.
- Typing — add debounced
typing:start/typing:stopevents. - Persistence — save messages and replay
historyon join. - Scale — add the
@socket.io/redis-adapterto run multiple instances.
Best Practices
- Authenticate in
io.use()middleware so unverified sockets never reach your event handlers. - Validate and trim every incoming payload — clients can emit anything, just like a REST body.
- Use rooms for grouping instead of manual socket lists; they scale and work across instances.
- Pick
socket.to()vsio.to()deliberately — one excludes the sender, the other includes them. - Keep typing indicators ephemeral; only persist real messages.
- Add the Redis adapter before running more than one Node process, or broadcasts won’t cross instances.
- Always re-emit presence on both join and disconnect so the member list never goes stale.