Client & Server Implementation
Let's build a real-time chat application from the ground up using Node.js, the ws package, and a browser client. We will implement room management, structured JSON messaging protocols, authentication, and broadcasting.
1. Setting Up the Node.js Server
Install the standard ws library:
npm install wsBasic Echo Server
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
console.log("⚡ WebSocket server listening on ws://localhost:8080");
wss.on("connection", (ws, req) => {
const clientIp = req.socket.remoteAddress;
console.log(`👤 New client connected from ${clientIp}`);
// Send a welcome message upon connecting
ws.send(JSON.stringify({
type: "welcome",
message: "Connected to real-time WebSocket server!"
}));
// Handle incoming data
ws.on("message", (rawMessage) => {
console.log("Received:", rawMessage.toString());
// Echo back to the sender
ws.send(JSON.stringify({
type: "echo",
message: rawMessage.toString()
}));
});
// Handle disconnection
ws.on("close", (code, reason) => {
console.log(`❌ Client disconnected: Code ${code} - Reason: ${reason}`);
});
// Handle errors
ws.on("error", (error) => {
console.error("Socket error:", error);
});
});2. Browser Client Implementation
Here is an HTML5/JavaScript frontend client with auto-scroll and status indicators:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Real-Time WebSocket Chat</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
#status { padding: 0.5rem; border-radius: 6px; font-weight: bold; margin-bottom: 1rem; }
.connected { background: #d4edda; color: #155724; }
.disconnected { background: #f8d7da; color: #721c24; }
#chat-box { border: 1px solid #ccc; height: 350px; overflow-y: auto; padding: 1rem; border-radius: 6px; }
.msg { margin: 0.4rem 0; padding: 0.5rem; border-radius: 4px; }
.received { background: #e3f2fd; }
.sent { background: #e8f5e9; text-align: right; }
.system { background: #fff3cd; font-style: italic; }
#input-group { display: flex; gap: 0.5rem; margin-top: 1rem; }
#msg-input { flex: 1; padding: 0.6rem; border: 1px solid #ccc; border-radius: 4px; }
button { padding: 0.6rem 1.2rem; cursor: pointer; border: none; background: #007bff; color: white; border-radius: 4px; }
</style>
</head>
<body>
<h2>Real-Time Chat</h2>
<div id="status" class="disconnected">Connecting...</div>
<div id="chat-box"></div>
<div id="input-group">
<input type="text" id="msg-input" placeholder="Type your message..." />
<button id="send-btn">Send</button>
</div>
<script>
const chatBox = document.getElementById("chat-box");
const statusDiv = document.getElementById("status");
const msgInput = document.getElementById("msg-input");
const sendBtn = document.getElementById("send-btn");
let socket;
function connect() {
socket = new WebSocket("ws://localhost:8080");
socket.onopen = () => {
statusDiv.textContent = "🟢 Connected";
statusDiv.className = "connected";
appendMessage("Connected to server", "system");
};
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
appendMessage(`${data.from || "Server"}: ${data.text || data.message}`, "received");
};
socket.onclose = () => {
statusDiv.textContent = "đź”´ Disconnected. Retrying...";
statusDiv.className = "disconnected";
appendMessage("Disconnected from server", "system");
setTimeout(connect, 3000); // Reconnect loop
};
}
function appendMessage(text, className) {
const el = document.createElement("div");
el.className = `msg ${className}`;
el.textContent = text;
chatBox.appendChild(el);
chatBox.scrollTop = chatBox.scrollHeight;
}
function sendMessage() {
const text = msgInput.value.trim();
if (!text || socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ type: "chat", text }));
appendMessage(`Me: ${text}`, "sent");
msgInput.value = "";
}
sendBtn.addEventListener("click", sendMessage);
msgInput.addEventListener("keydown", (e) => { if (e.key === "Enter") sendMessage(); });
connect();
</script>
</body>
</html>3. Implementing Chat Rooms & Broadcasting
To support rooms (e.g., #general, #engineering), use a Map of Sets to track room membership on the server:
Room Management Functions
import { WebSocketServer, WebSocket } from "ws";
const wss = new WebSocketServer({ port: 8080 });
const rooms = new Map(); // roomName -> Set<ws>
export function joinRoom(ws, roomName) {
if (!rooms.has(roomName)) {
rooms.set(roomName, new Set());
}
rooms.get(roomName).add(ws);
ws.rooms = ws.rooms || new Set();
ws.rooms.add(roomName);
broadcastToRoom(roomName, {
type: "notification",
text: `User ${ws.userId} joined ${roomName}`
}, ws); // Exclude the joiner
}
export function leaveRoom(ws, roomName) {
const room = rooms.get(roomName);
if (!room) return;
room.delete(ws);
ws.rooms?.delete(roomName);
if (room.size === 0) {
rooms.delete(roomName); // Clean up memory
} else {
broadcastToRoom(roomName, {
type: "notification",
text: `User ${ws.userId} left ${roomName}`
});
}
}
export function broadcastToRoom(roomName, payload, excludeSocket = null) {
const room = rooms.get(roomName);
if (!room) return;
const data = JSON.stringify(payload);
room.forEach((client) => {
if (client !== excludeSocket && client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
}4. Structured Message Protocol Design
Instead of sending raw unstructured strings, always use a typed message envelope:
{
"type": "chat_message",
"payload": {
"room": "general",
"text": "Hey everyone!",
"timestamp": 1718000000
},
"meta": {
"userId": "usr_9981",
"clientMsgId": "msg_abc123"
}
}Router Pattern on Server
const messageHandlers = {
join_room: (ws, payload) => joinRoom(ws, payload.room),
leave_room: (ws, payload) => leaveRoom(ws, payload.room),
chat_message: (ws, payload) => {
broadcastToRoom(payload.room, {
type: "chat_message",
from: ws.username,
room: payload.room,
text: payload.text,
timestamp: Date.now()
});
}
};
ws.on("message", (raw) => {
try {
const { type, payload } = JSON.parse(raw);
const handler = messageHandlers[type];
if (handler) {
handler(ws, payload);
} else {
console.warn("Unknown message type:", type);
}
} catch (err) {
ws.send(JSON.stringify({ type: "error", message: "Malformed JSON message" }));
}
});5. WebSocket Authentication Strategies
Strategy A: Token in URL Query Parameter (Handshake Auth)
The client passes a JWT inside the connection URL during the initial HTTP handshake:
// Client
const ws = new WebSocket("wss://example.com/ws?token=eyJhbGciOi...");
// Server
import url from "url";
import jwt from "jsonwebtoken";
wss.on("connection", (ws, req) => {
const queryParams = new URLSearchParams(url.parse(req.url).query);
const token = queryParams.get("token");
try {
const user = jwt.verify(token, process.env.JWT_SECRET);
ws.user = user;
ws.userId = user.id;
} catch (err) {
// Terminate unauthorized connection immediately
ws.close(1008, "Unauthorized: Invalid or expired token");
}
});Strategy B: First-Message Authentication (Post-Connection)
For scenarios where query parameters might be logged in web server access logs, require the client to send an authentication payload within 5 seconds of connecting:
wss.on("connection", (ws) => {
ws.isAuthenticated = false;
// Set 5-second auth timeout
const authTimeout = setTimeout(() => {
if (!ws.isAuthenticated) {
ws.close(1008, "Auth Timeout: Token not provided within 5s");
}
}, 5000);
ws.on("message", (raw) => {
const data = JSON.parse(raw);
if (!ws.isAuthenticated) {
if (data.type === "auth" && verifyToken(data.token)) {
ws.isAuthenticated = true;
clearTimeout(authTimeout);
ws.send(JSON.stringify({ type: "auth_success" }));
} else {
ws.close(1008, "Invalid credentials");
}
return;
}
// Process regular messages only after authentication succeeds
handleMessage(ws, data);
});
});6. Complete Production Chat Server (server.js)
import http from "http";
import { WebSocketServer, WebSocket } from "ws";
const server = http.createServer((req, res) => {
if (req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ status: "healthy", connections: wss.clients.size }));
}
res.writeHead(404);
res.end();
});
const wss = new WebSocketServer({ server });
const rooms = new Map();
wss.on("connection", (ws) => {
ws.isAlive = true;
ws.userId = `usr_${Math.random().toString(36).substring(7)}`;
ws.rooms = new Set();
ws.on("pong", () => { ws.isAlive = true; });
ws.on("message", (raw) => {
try {
const { action, room, text } = JSON.parse(raw);
if (action === "join") {
if (!rooms.has(room)) rooms.set(room, new Set());
rooms.get(room).add(ws);
ws.rooms.add(room);
broadcast(room, { type: "system", text: `${ws.userId} joined ${room}` });
}
if (action === "message" && ws.rooms.has(room)) {
broadcast(room, { type: "chat", from: ws.userId, room, text, timestamp: Date.now() });
}
} catch (err) {
ws.send(JSON.stringify({ error: "Invalid JSON format" }));
}
});
ws.on("close", () => {
ws.rooms.forEach((r) => {
const roomSet = rooms.get(r);
roomSet?.delete(ws);
if (roomSet?.size === 0) rooms.delete(r);
else broadcast(r, { type: "system", text: `${ws.userId} left` });
});
});
});
function broadcast(room, payload) {
const roomSet = rooms.get(room);
if (!roomSet) return;
const msg = JSON.stringify(payload);
roomSet.forEach((client) => {
if (client.readyState === WebSocket.OPEN) client.send(msg);
});
}
// 30-second ping interval
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on("close", () => clearInterval(interval));
const PORT = process.env.PORT || 8080;
server.listen(PORT, () => console.log(`🚀 Chat server active on port ${PORT}`));👉 Next Step: Learn how to scale across multiple server instances using Redis Pub/Sub in Scaling WebSockets with Redis Pub/Sub.