Building a Real-Time Chat App with Node.js and WebSocket
Chat apps seem complex. They're not. Here's a complete working example.
What You'll Build
Browser ←→ WebSocket ←→ Node.js Server ←→ WebSocket ←→ Browser
↕
In-memory store (or DB)
Step 1: Basic WebSocket Server
// server.js
const { WebSocketServer } = require('ws');
const http = require('http');
const url = require('url');
const server = http.createServer((req, res) => {
// Serve static files or API
res.writeHead(200);
res.end('WebSocket server running');
});
const wss = new WebSocketServer({ server, path: '/ws' });
// Store connections
const clients = new Map(); // ws → { id, name, room }
wss.on('connection', (ws, req) => {
const params = new url.URL(req.url, `http://${req.headers.host}`).searchParams;
const roomId = params.get('room') || 'default';
const userId = `user_${Date.now()}_${Math.random().toString(36).slice(2)}`;
clients.set(ws, { id: userId, room: roomId });
console.log(`Client connected: ${userId} in room ${roomId}`);
console.log(`Total clients: ${clients.size}`);
// Send welcome message
sendTo(ws, { type: 'system', message: 'Welcome! Choose a nickname.' });
// Handle incoming messages
ws.on('message', (raw) => {
try {
const data = JSON.parse(raw.toString());
handleMessage(ws, data);
} catch (e) {
sendError(ws, 'Invalid JSON');
}
});
// Handle disconnect
ws.on('close', () => {
const client = clients.get(ws);
if (client) {
broadcast(client.room, {
type: 'leave',
user: client.id,
message: `${client.name || 'Someone'} left`,
});
clients.delete(ws);
console.log(`Disconnected: ${client.id}. Remaining: ${clients.size}`);
}
});
// Handle errors
ws.on('error', (err) => {
console.error('WebSocket error:', err.message);
});
});
Step 2: Message Handling
function handleMessage(sender, data) {
const senderInfo = clients.get(sender);
switch (data.type) {
case 'join':
// User sets their name
senderInfo.name = data.name || 'Anonymous';
broadcast(senderInfo.room, {
type: 'join',
user: senderInfo.id,
name: senderInfo.name,
message: `${senderInfo.name} joined the chat`,
});
break;
case 'message':
// Regular chat message
if (!senderInfo.name) {
return sendError(sender, 'Set your name first with type:"join"');
}
broadcast(senderInfo.room, {
type: 'message',
user: senderInfo.id,
name: senderInfo.name,
text: data.text,
timestamp: new Date().toISOString(),
});
break;
case 'typing':
// Typing indicator
broadcastExcept(sender, senderInfo.room, {
type: 'typing',
user: senderInfo.id,
name: senderInfo.name,
});
break;
case 'private':
// Direct message to specific user
sendPrivateMessage(sender, data.targetId, data.text);
break;
case 'users':
// List online users in room
const users = getRoomUsers(senderInfo.room);
sendTo(sender, { type: 'users', users });
break;
default:
sendError(sender, `Unknown message type: ${data.type}`);
}
}
Step 3: Broadcasting
function broadcast(room, message) {
const payload = JSON.stringify(message);
for (const [ws, client] of clients) {
if (client.room === room && ws.readyState === ws.OPEN) {
ws.send(payload);
}
}
}
function broadcastExcept(sender, room, message) {
const payload = JSON.stringify(message);
for (const [ws, client] of clients) {
if (client.room === room && ws !== sender && ws.readyState === ws.OPEN) {
ws.send(payload);
}
}
}
function sendTo(ws, message) {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(message));
}
}
function sendError(ws, message) {
sendTo(ws, { type: 'error', message });
}
function getRoomUsers(room) {
const users = [];
for (const [ws, client] of clients) {
if (client.room === room && client.name) {
users.push({ id: client.id, name: client.name });
}
}
return users;
}
function sendPrivateMessage(sender, targetId, text) {
const senderInfo = clients.get(sender);
for (const [ws, client] of clients) {
if (client.id === targetId) {
sendTo(ws, {
type: 'private',
from: senderInfo.id,
fromName: senderInfo.name,
text,
timestamp: new Date().toISOString(),
});
// Also send copy to sender
sendTo(sender, {
type: 'private',
to: targetId,
text,
timestamp: new Date().toISOString(),
});
return;
}
}
sendError(sender, `User ${targetId} not found`);
}
Step 4: Frontend
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Real-Time Chat</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; height: 100vh; display: flex; flex-direction: column; }
#header { background: #1e293b; padding: 1rem; border-bottom: 1px solid #334155; display: flex; justify-content: space-between; align-items: center; }
#header h1 { font-size: 1.25rem; font-weight: 600; }
#online-count { color: #94a3b8; font-size: 0.875rem; }
#messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem; }
.msg { max-width: 80%; padding: 0.75rem 1rem; border-radius: 12px; word-wrap: break-word; }
.msg.system { align-self: center; background: transparent; color: #64748b; font-size: 0.8rem; }
.msg.incoming { align-self: flex-start; background: #1e293b; border-bottom-left-radius: 4px; }
.msg.outgoing { align-self: flex-end; background: #3b82f6; color: white; border-bottom-right-radius: 4px; }
.msg-name { font-weight: 600; font-size: 0.8rem; margin-bottom: 2px; opacity: 0.7; }
.msg-text { line-height: 1.4; }
.msg-time { font-size: 0.7rem; margin-top: 4px; opacity: 0.5; }
#input-area { display: flex; gap: 0.5rem; padding: 1rem; background: #1e293b; border-top: 1px solid #334155; }
#input { flex: 1; padding: 0.75rem 1rem; border: 1px solid #334155; border-radius: 8px; background: #0f172a; color: #e2e8f0; font-size: 1rem; outline: none; }
#input:focus { border-color: #3b82f6; }
#send-btn { padding: 0.75rem 1.5rem; background: #3b82f6; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; }
#send-btn:hover { background: #2563eb; }
#send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
#typing-indicator { padding: 0 1rem; font-size: 0.8rem; color: #64748b; height: 20px; }
#name-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 100; }
#name-modal.hidden { display: none; }
#name-modal input { padding: 1rem; font-size: 1.125rem; border-radius: 8px; border: 2px solid #3b82f6; width: 300px; outline: none; }
#name-modal button { margin-top: 1rem; padding: 0.75rem 2rem; background: #3b82f6; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 1rem; }
</style>
</head>
<body>
<div id="name-modal">
<div style="text-align:center;">
<h2>Join Chat</h2>
<input id="name-input" placeholder="Your name" maxlength="30" />
<br><button onclick="joinChat()">Join</button>
</div>
</div>
<div id="header">
<h1>💬 Real-Time Chat</h1>
<span id="online-count">0 online</span>
</div>
<div id="messages"></div>
<div id="typing-indicator"></div>
<div id="input-area">
<input id="input" placeholder="Type a message..." autocomplete="off" />
<button id="send-btn" onclick="sendMessage()">Send</button>
</div>
<script>
let ws;
let myName = '';
let typingTimer;
function connect() {
const room = new URLSearchParams(location.search).get('room') || 'default';
ws = new WebSocket(`ws://${location.host}/ws?room=${room}`);
ws.onopen = () => console.log('Connected');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleMessage(data);
};
ws.onclose = () => {
addMessage({ type: 'system', message: 'Disconnected. Reconnecting...' });
setTimeout(connect, 3000);
};
ws.onerror = () => console.error('WebSocket error');
}
function handleMessage(data) {
switch (data.type) {
case 'message':
addMessage(data);
break;
case 'system':
addMessage(data);
updateOnlineCount();
break;
case 'join':
case 'leave':
addMessage(data);
updateOnlineCount();
break;
case 'typing':
showTyping(data.name);
break;
case 'error':
alert(data.message);
break;
case 'users':
updateOnlineCount(data.users.length);
break;
}
}
function joinChat() {
const nameInput = document.getElementById('name-input');
myName = nameInput.value.trim();
if (!myName) return nameInput.focus();
document.getElementById('name-modal').classList.add('hidden');
connect();
setTimeout(() => {
ws.send(JSON.stringify({ type: 'join', name: myName }));
}, 500);
}
function sendMessage() {
const input = document.getElementById('input');
const text = input.value.trim();
if (!text || !ws) return;
ws.send(JSON.stringify({ type: 'message', text }));
input.value = '';
input.focus();
}
function addMessage(msg) {
const container = document.getElementById('messages');
const div = document.createElement('div');
div.className = 'msg';
if (msg.type === 'system') div.classList.add('system');
else if (msg.name === myName) div.classList.add('outgoing');
else div.classList.add('incoming');
div.innerHTML = `
${msg.name ? `<div class="msg-name">${escapeHtml(msg.name)}</div>` : ''}
<div class="msg-text">${escapeHtml(msg.text || msg.message)}</div>
${msg.timestamp ? `<div class="msg-time">${new Date(msg.timestamp).toLocaleTimeString()}</div>` : ''}
`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
function showTyping(name) {
const el = document.getElementById('typing-indicator');
el.textContent = `${name} is typing...`;
clearTimeout(typingTimer);
typingTimer = setTimeout(() => el.textContent = '', 2000);
}
function updateOnlineCount(count) {
if (count !== undefined) {
document.getElementById('online-count').textContent = `${count} online`;
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Enter key sends message
document.getElementById('input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Allow Enter key in name modal
document.getElementById('name-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') joinChat();
});
</script>
</body>
</html>
Step 5: Run It
# Install dependency
npm install ws
# Start server
node server.js
# Open browser → http://localhost:3000
# Open another tab → http://localhost:3000
# Type messages → they appear in real-time!
Production Considerations
// 1. Authentication (add JWT verification)
import jwt from 'jsonwebtoken';
wss.on('connection', (ws, req) => {
const token = new URL(req.url, 'http://localhost').searchParams.get('token');
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
clients.set(ws, { id: payload.userId, name: payload.name, ... });
} catch {
ws.close(4001, 'Authentication failed');
return;
}
});
// 2. Persistence (store messages in database)
async function saveMessage(room, user, text) {
await db.run(
'INSERT INTO messages (room, user_id, name, text, created_at) VALUES (?, ?, ?, ?, datetime("now"))',
[room, user.id, user.name, text]
);
}
// Send recent history on join
async function sendHistory(ws, room) {
const messages = await db.all(
'SELECT * FROM messages WHERE room = ? ORDER BY created_at DESC LIMIT 50',
[room]
);
sendTo(ws, { type: 'history', messages: messages.reverse() });
}
// 3. Rate limiting per connection
const rateLimit = new Map(); // ws → { count, resetTime }
function checkRateLimit(ws) {
const now = Date.now();
const limit = rateLimit.get(ws);
if (!limit || now > limit.resetTime) {
rateLimit.set(ws, { count: 1, resetTime: now + 60000 }); // 60/min
return true;
}
if (limit.count >= 60) {
sendError(ws, 'Rate limited. Slow down!');
return false;
}
limit.count++;
return true;
}
// 4. Scale with Redis Pub/Sub (multiple servers)
const Redis = require('ioredis');
const pub = new Redis(process.env.REDIS_URL);
const sub = new Redis(process.env.REDIS_URL);
sub.subscribe('chat:default', (message) => {
const data = JSON.parse(message);
broadcast('default', data); // Forward to local connections
});
// When receiving a message:
pub.publish(`chat:${room}`, JSON.stringify(messageData));
Want to take this further? Add rooms, file sharing, reactions, read receipts...
Follow @armorbreak for more Node.js content.
Top comments (0)