Real-time communication is everywhere — from Slack and Discord to live dashboards and collaborative editors. WebSockets remain the gold standard for bidirectional, low-latency communication on the web. In this hands-on guide, you’ll build a fully functional real-time chat application using Node.js and the modern ws library, complete with rooms, usernames, and message broadcasting.
Why WebSockets in 2026?
While Server-Sent Events (SSE) and HTTP/3 have gained traction, WebSockets still dominate use cases that require true two-way communication. Unlike HTTP polling, a WebSocket connection stays open, letting both server and client push messages instantly with minimal overhead — typically just 2 bytes of framing per message.
Project Setup
Let’s scaffold a new Node.js project. We’ll use Node 22+ (LTS in 2026) which has native TypeScript support via type stripping, but we’ll keep this example in plain JavaScript for accessibility.
mkdir realtime-chat && cd realtime-chat
npm init -y
npm install wsThat’s it — no Express needed. The ws library is lightweight, fast, and production-tested.
Building the WebSocket Server
Create server.js with room-based chat support:
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
const rooms = new Map(); // room name -> Set of clients
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.room = null;
ws.username = 'Anonymous';
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (data) => {
let msg;
try {
msg = JSON.parse(data);
} catch {
ws.send(JSON.stringify({ type: 'error', text: 'Invalid JSON' }));
return;
}
switch (msg.type) {
case 'join':
handleJoin(ws, msg);
break;
case 'chat':
handleChat(ws, msg);
break;
case 'leave':
handleLeave(ws);
break;
default:
ws.send(JSON.stringify({ type: 'error', text: 'Unknown message type' }));
}
});
ws.on('close', () => handleLeave(ws));
});
function handleJoin(ws, msg) {
const room = msg.room || 'general';
const username = msg.username || 'Anonymous';
// Leave current room first
if (ws.room) handleLeave(ws);
ws.room = room;
ws.username = username;
if (!rooms.has(room)) rooms.set(room, new Set());
rooms.get(room).add(ws);
broadcast(room, {
type: 'system',
text: `${username} joined #${room}`,
users: [...rooms.get(room)].map(c => c.username)
});
}
function handleChat(ws, msg) {
if (!ws.room) {
ws.send(JSON.stringify({ type: 'error', text: 'Join a room first' }));
return;
}
broadcast(ws.room, {
type: 'chat',
username: ws.username,
text: msg.text,
timestamp: Date.now()
});
}
function handleLeave(ws) {
if (!ws.room) return;
const room = ws.room;
const clients = rooms.get(room);
if (clients) {
clients.delete(ws);
if (clients.size === 0) rooms.delete(room);
else broadcast(room, {
type: 'system',
text: `${ws.username} left #${room}`,
users: [...clients].map(c => c.username)
});
}
ws.room = null;
}
function broadcast(room, message) {
const payload = JSON.stringify(message);
const clients = rooms.get(room);
if (!clients) return;
for (const client of clients) {
if (client.readyState === 1) client.send(payload);
}
}
// Heartbeat: detect broken connections
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => clearInterval(interval));
console.log('WebSocket server running on ws://localhost:8080');Building a Browser Client
Create index.html — a minimal but functional chat UI:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Realtime Chat</title>
<style>
body { font-family: system-ui; max-width: 600px; margin: 2rem auto; }
#messages { height: 400px; overflow-y: auto; border: 1px solid #ccc;
padding: 1rem; margin: 1rem 0; border-radius: 8px; }
.system { color: #888; font-style: italic; }
input, button { padding: 0.5rem; font-size: 1rem; }
</style>
</head>
<body>
<h1>💬 Realtime Chat</h1>
<div id="setup">
<input id="username" placeholder="Username" />
<input id="room" placeholder="Room (default: general)" />
<button onclick="joinRoom()">Join</button>
</div>
<div id="messages"></div>
<input id="input" placeholder="Type a message..." onkeydown="if(event.key==='Enter')sendMsg()" />
<button onclick="sendMsg()">Send</button>
<script>
const ws = new WebSocket('ws://localhost:8080');
const messages = document.getElementById('messages');
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
const div = document.createElement('div');
if (msg.type === 'system') {
div.className = 'system';
div.textContent = msg.text;
} else if (msg.type === 'chat') {
div.textContent = `[${msg.username}]: ${msg.text}`;
} else if (msg.type === 'error') {
div.style.color = 'red';
div.textContent = `Error: ${msg.text}`;
}
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
};
function joinRoom() {
ws.send(JSON.stringify({
type: 'join',
username: document.getElementById('username').value || 'Anonymous',
room: document.getElementById('room').value || 'general'
}));
}
function sendMsg() {
const input = document.getElementById('input');
ws.send(JSON.stringify({ type: 'chat', text: input.value }));
input.value = '';
}
</script>
</body>
</html>Running and Testing
Start the server and open index.html in multiple browser tabs:
node server.js
# Open index.html in 2+ browser tabs
# Enter different usernames, join the same room, and chat!You can also test from the terminal using wscat:
npx wscat -c ws://localhost:8080
# Then type: {"type":"join","username":"terminal-user","room":"general"}
# And: {"type":"chat","text":"Hello from the terminal!"}Production Considerations
Before deploying this to production, address these critical areas:
1. Use a Reverse Proxy
Run behind Nginx or Caddy to handle TLS termination and upgrade HTTP to WebSocket:
# Nginx config snippet
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}2. Rate Limiting
Prevent spam by tracking message rates per client:
function handleChat(ws, msg) {
const now = Date.now();
ws.msgTimestamps = (ws.msgTimestamps || []).filter(t => now - t < 10000);
if (ws.msgTimestamps.length >= 10) {
ws.send(JSON.stringify({ type: 'error', text: 'Slow down! Max 10 msgs/10s' }));
return;
}
ws.msgTimestamps.push(now);
// ... rest of handler
}3. Scaling with Redis
For multi-server deployments, use Redis Pub/Sub to share messages across Node.js instances. Libraries like @socket.io/redis-adapter make this straightforward, or you can implement it directly with ioredis.
4. Authentication
Validate JWTs during the WebSocket upgrade handshake rather than after connection:
wss.on('headers', (headers, req) => {
const token = new URL(req.url, 'http://localhost').searchParams.get('token');
if (!verifyJWT(token)) req.destroy();
});Key Takeaways
- WebSockets provide true bidirectional communication with minimal overhead
- The
wslibrary is all you need — no heavy frameworks required - Heartbeats (ping/pong) are essential to detect dead connections
- Always implement rate limiting and input validation in production
- Use Redis Pub/Sub when scaling horizontally across multiple servers
- Handle TLS termination at the reverse proxy level, not in Node.js
WebSockets remain a fundamental tool in every backend developer’s toolkit. With just Node.js and the ws library, you can build powerful real-time features — from chat apps to live notifications to collaborative editing. The patterns shown here scale from weekend projects to production systems serving thousands of concurrent users.

Leave a Reply