March 29, 202610 min read

Build a Real-Time Chat App with WebSockets and Node.js

Step-by-step guide to building a production-ready real-time chat application using WebSockets, Node.js, and vanilla JavaScript. Covers rooms, typing indicators, reconnection, and deployment.

websockets nodejs javascript real-time codeup
Ad 336x280

I've built maybe a dozen chat apps over the years. The first few were over-engineered messes -- React frontends, Redux state machines, Socket.IO with every bell and whistle turned on. The one that actually shipped fastest? Plain WebSockets, a Node.js server, and maybe 200 lines of frontend code.

That's what we're building here. Not a toy. A real chat app with rooms, user presence, typing indicators, message history, and proper reconnection logic. But without the bloat.

Why Raw WebSockets Instead of Socket.IO

Socket.IO is fine. I've used it plenty. But it adds a 40KB client library, automatic fallback to long-polling (which you probably don't need in 2026), and its own protocol on top of WebSockets. For a chat app, raw WebSockets give you everything you need with zero overhead.

The ws library on Node.js is fast, battle-tested, and stays out of your way.

The Server

Install the dependencies:

npm init -y
npm install ws uuid

Here's the complete server:

// server.js
const { WebSocketServer } = require('ws');
const { v4: uuidv4 } = require('uuid');
const http = require('http');

const server = http.createServer();
const wss = new WebSocketServer({ server });

const rooms = new Map(); // roomId -> Set<ws>
const clients = new Map(); // ws -> { id, username, room }
const messageHistory = new Map(); // roomId -> messages[]

const MAX_HISTORY = 100;

function broadcast(room, message, exclude = null) {
const members = rooms.get(room);
if (!members) return;

const payload = JSON.stringify(message);
for (const client of members) {
if (client !== exclude && client.readyState === 1) {
client.send(payload);
}
}
}

function getRoomUsers(room) {
const members = rooms.get(room);
if (!members) return [];
return [...members]
.map(ws => clients.get(ws))
.filter(Boolean)
.map(({ id, username }) => ({ id, username }));
}

wss.on('connection', (ws) => {
const clientId = uuidv4();

ws.on('message', (raw) => {
let data;
try {
data = JSON.parse(raw);
} catch {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
return;
}

switch (data.type) {
case 'join': {
const { username, room } = data;
if (!username || !room) return;

// Leave previous room if any
const prev = clients.get(ws);
if (prev?.room) {
rooms.get(prev.room)?.delete(ws);
broadcast(prev.room, {
type: 'user_left',
userId: prev.id,
username: prev.username,
users: getRoomUsers(prev.room)
});
}

// Join new room
clients.set(ws, { id: clientId, username, room });
if (!rooms.has(room)) rooms.set(room, new Set());
rooms.get(room).add(ws);

// Send history
const history = messageHistory.get(room) || [];
ws.send(JSON.stringify({
type: 'joined',
clientId,
room,
history,
users: getRoomUsers(room)
}));

// Notify others
broadcast(room, {
type: 'user_joined',
userId: clientId,
username,
users: getRoomUsers(room)
}, ws);
break;
}

case 'message': {
const client = clients.get(ws);
if (!client?.room) return;

const msg = {
id: uuidv4(),
userId: client.id,
username: client.username,
text: data.text?.slice(0, 2000), // limit length
timestamp: Date.now()
};

// Store in history
if (!messageHistory.has(client.room)) {
messageHistory.set(client.room, []);
}
const history = messageHistory.get(client.room);
history.push(msg);
if (history.length > MAX_HISTORY) history.shift();

// Broadcast to everyone including sender
broadcast(client.room, { type: 'message', ...msg });
break;
}

case 'typing': {
const client = clients.get(ws);
if (!client?.room) return;
broadcast(client.room, {
type: 'typing',
userId: client.id,
username: client.username,
isTyping: !!data.isTyping
}, ws);
break;
}
}
});

ws.on('close', () => {
const client = clients.get(ws);
if (client?.room) {
rooms.get(client.room)?.delete(ws);
if (rooms.get(client.room)?.size === 0) {
rooms.delete(client.room);
}
broadcast(client.room, {
type: 'user_left',
userId: client.id,
username: client.username,
users: getRoomUsers(client.room)
});
}
clients.delete(ws);
});

ws.on('error', (err) => {
console.error('WebSocket error:', err.message);
});
});

const PORT = process.env.PORT || 3001;
server.listen(PORT, () => {
console.log(Chat server running on port ${PORT});
});

A few things worth noting. I'm using a plain Map for rooms and clients -- this works fine for a single-process server. If you need horizontal scaling, you'd swap this for Redis pub/sub (which I'll touch on at the end).

The message history is capped at 100 messages per room. No database, no persistence. When the server restarts, history is gone. For a production app you'd obviously write to a database, but this keeps things focused.

The Client

No framework. No build step. Just an HTML file:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Chat</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: system-ui, sans-serif; height: 100vh; display: flex; }

.sidebar {
width: 200px; background: #1a1a2e; color: #eee; padding: 16px;
display: flex; flex-direction: column;
}
.sidebar h3 { margin-bottom: 12px; font-size: 14px; color: #888; }
.user-list { list-style: none; }
.user-list li { padding: 4px 0; font-size: 14px; }
.user-list li::before { content: "● "; color: #4ade80; }

.chat-area {
flex: 1; display: flex; flex-direction: column; background: #0f0f23;
}
.messages {
flex: 1; overflow-y: auto; padding: 16px;
display: flex; flex-direction: column; gap: 8px;
}
.msg { max-width: 70%; padding: 8px 12px; border-radius: 8px; }
.msg.mine { align-self: flex-end; background: #3b82f6; color: white; }
.msg.theirs { align-self: flex-start; background: #1e293b; color: #eee; }
.msg .meta { font-size: 11px; color: rgba(255,255,255,0.5); margin-bottom: 2px; }
.msg.system { align-self: center; color: #666; font-size: 13px; font-style: italic; }

.typing-indicator { padding: 4px 16px; color: #666; font-size: 13px; height: 24px; }

.input-area {
display: flex; padding: 12px; gap: 8px; background: #1a1a2e;
}
.input-area input {
flex: 1; padding: 10px; border: 1px solid #333; border-radius: 6px;
background: #0f0f23; color: #eee; font-size: 14px; outline: none;
}
.input-area button {
padding: 10px 20px; background: #3b82f6; color: white;
border: none; border-radius: 6px; cursor: pointer; font-size: 14px;
}

.join-screen {
display: flex; flex-direction: column; align-items: center;
justify-content: center; height: 100vh; width: 100%; gap: 12px;
background: #0f0f23; color: #eee;
}
.join-screen input {
padding: 10px; width: 250px; border: 1px solid #333;
border-radius: 6px; background: #1a1a2e; color: #eee; font-size: 14px;
}
</style>
</head>
<body>
<div id="join" class="join-screen">
<h1>Join Chat</h1>
<input id="username" placeholder="Username" />
<input id="room" placeholder="Room name" value="general" />
<button onclick="joinRoom()">Join</button>
</div>

<div id="app" style="display:none; width:100%; display:none;">
<div class="sidebar">
<h3>Online</h3>
<ul id="users" class="user-list"></ul>
</div>
<div class="chat-area">
<div id="messages" class="messages"></div>
<div id="typing" class="typing-indicator"></div>
<div class="input-area">
<input id="input" placeholder="Type a message..."
onkeydown="handleKey(event)" oninput="handleTyping()" />
<button onclick="sendMessage()">Send</button>
</div>
</div>
</div>

<script>
let ws;
let myId;
let typingTimeout;
let isTyping = false;
const typingUsers = new Map();

function connect(username, room) {
ws = new WebSocket(ws://${location.hostname}:3001);

ws.onopen = () => {
ws.send(JSON.stringify({ type: 'join', username, room }));
};

ws.onmessage = (event) => {
const data = JSON.parse(event.data);

switch (data.type) {
case 'joined':
myId = data.clientId;
data.history.forEach(renderMessage);
updateUsers(data.users);
addSystem(Joined #${data.room});
break;
case 'message':
renderMessage(data);
break;
case 'user_joined':
addSystem(${data.username} joined);
updateUsers(data.users);
break;
case 'user_left':
addSystem(${data.username} left);
updateUsers(data.users);
break;
case 'typing':
handleRemoteTyping(data);
break;
}
};

ws.onclose = () => {
addSystem('Disconnected. Reconnecting...');
setTimeout(() => connect(username, room), 2000);
};
}

function renderMessage({ userId, username, text, timestamp }) {
const div = document.createElement('div');
div.className = msg ${userId === myId ? 'mine' : 'theirs'};
div.innerHTML =
<div class="meta">${username} · ${new Date(timestamp).toLocaleTimeString()}</div>
<div>${escapeHtml(text)}</div>
;
const container = document.getElementById('messages');
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}

function addSystem(text) {
const div = document.createElement('div');
div.className = 'msg system';
div.textContent = text;
document.getElementById('messages').appendChild(div);
}

function updateUsers(users) {
const ul = document.getElementById('users');
ul.innerHTML = users.map(u => <li>${escapeHtml(u.username)}</li>).join('');
}

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 = '';
sendTypingStatus(false);
}

function handleKey(e) {
if (e.key === 'Enter') sendMessage();
}

function handleTyping() {
if (!isTyping) sendTypingStatus(true);
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => sendTypingStatus(false), 1500);
}

function sendTypingStatus(typing) {
isTyping = typing;
ws?.send(JSON.stringify({ type: 'typing', isTyping: typing }));
}

function handleRemoteTyping({ userId, username, isTyping: typing }) {
if (typing) typingUsers.set(userId, username);
else typingUsers.delete(userId);

const names = [...typingUsers.values()];
const el = document.getElementById('typing');
if (names.length === 0) el.textContent = '';
else if (names.length === 1) el.textContent = ${names[0]} is typing...;
else el.textContent = ${names.join(', ')} are typing...;
}

function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}

function joinRoom() {
const username = document.getElementById('username').value.trim();
const room = document.getElementById('room').value.trim();
if (!username || !room) return;

document.getElementById('join').style.display = 'none';
document.getElementById('app').style.display = 'flex';
connect(username, room);
}
</script>
</body>
</html>

The Reconnection Logic

Notice the onclose handler:

ws.onclose = () => {
  addSystem('Disconnected. Reconnecting...');
  setTimeout(() => connect(username, room), 2000);
};

This is the simplest reconnection strategy -- wait 2 seconds, try again. For production, you want exponential backoff:

let reconnectAttempts = 0;

ws.onclose = () => {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectAttempts++;
addSystem(Disconnected. Reconnecting in ${delay / 1000}s...);
setTimeout(() => connect(username, room), delay);
};

ws.onopen = () => {
reconnectAttempts = 0; // Reset on successful connection
// ...
};

This backs off from 1s to 2s to 4s to 8s... up to 30 seconds max. Prevents hammering a server that's down.

Handling Edge Cases

A few things that'll bite you if you don't think about them early:

Message ordering

WebSocket messages are ordered per-connection. But if a user disconnects and reconnects quickly, they might miss messages sent during the gap. The server sends history on join, which covers this for recent messages.

User enumeration

In the current implementation, anyone can see who's in a room. For a private chat, you'd add authentication and only show room members to authorized users.

Message size limits

The text?.slice(0, 2000) on the server prevents anyone from sending a 10MB message through the pipe. You should also add rate limiting:

const rateLimits = new Map(); // clientId -> { count, resetTime }

function checkRateLimit(clientId) {
const now = Date.now();
const limit = rateLimits.get(clientId);

if (!limit || now > limit.resetTime) {
rateLimits.set(clientId, { count: 1, resetTime: now + 10000 });
return true;
}

limit.count++;
return limit.count <= 20; // 20 messages per 10 seconds
}

Scaling Beyond a Single Server

The in-memory approach works until you need multiple server instances. Then you need a pub/sub layer so messages reach clients on different servers.

Redis is the standard choice:

const Redis = require('ioredis');
const pub = new Redis();
const sub = new Redis();

// When a message comes in, publish to Redis instead of broadcasting directly
sub.subscribe('chat');

sub.on('message', (channel, payload) => {
const { room, message } = JSON.parse(payload);
// Broadcast to clients connected to THIS server
broadcastLocal(room, message);
});

// In your message handler:
pub.publish('chat', JSON.stringify({ room: client.room, message: msg }));

Each server instance subscribes to the same Redis channel. When any server receives a message, it publishes to Redis, and every server broadcasts to its local clients.

Deploying This

For a quick deployment, you can run this behind nginx with WebSocket proxy support:

server {
    listen 80;
    server_name chat.example.com;

location / {
proxy_pass http://127.0.0.1:3001;
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; # Keep connection alive for 24h
}
}

The proxy_read_timeout is important -- without it, nginx will close idle WebSocket connections after 60 seconds by default.

What We Built

A chat app with rooms, user presence, typing indicators, message history, and reconnection -- under 300 lines of server code and 150 lines of client JavaScript. No React. No build tools. No npm packages on the client.

Could you add more? Sure. Persistent storage, file attachments, read receipts, threads. But the WebSocket foundation doesn't change. It's just more message types on the same pipe.

That's the beauty of WebSockets. Once you have the connection, everything else is just JSON messages going back and forth.

Ad 728x90