Build a WebSocket Chat Server
Introduction
Real-time chat applications are a cornerstone of modern web development. In this tutorial, we'll build a complete WebSocket chat server with rooms, private messaging, user presence, and authentication.
WebSockets provide bidirectional, persistent connections between clients and servers, making them ideal for real-time applications.
- A WebSocket server with connection handling
- Chat rooms with join/leave functionality
- Private messaging between users
- User presence tracking
- Basic authentication
- Message history
- WebSocket protocol fundamentals
- Real-time message broadcasting
- Room-based architecture
- User session management
- Connection lifecycle handling
- Event-driven architecture
Core Concepts
WebSocket Protocol
WebSockets start as HTTP requests with an "Upgrade" header. Once upgraded, the connection remains open for bidirectional communication. Unlike HTTP's request-response model, WebSockets allow either side to send messages at any time.
Message Types
We'll implement various message types:
- CHAT_MESSAGE - Regular chat messages
- JOIN_ROOM - User joins a room
- LEAVE_ROOM - User leaves a room
- PRIVATE_MESSAGE - Direct messages
- USER_JOINED - Notification when user joins
- USER_LEFT - Notification when user leaves
- TYPING - Typing indicators
Room Architecture
Chat rooms allow users to communicate in groups. Each room maintains a list of active users, message history, and room metadata.
Project Overview
Our chat server will include:
| Feature | Description |
|---|---|
| WebSocket Server | Handle connections and messages |
| Room Management | Create, join, leave rooms |
| Private Messaging | Direct messages between users |
| Presence System | Track online/offline status |
| Message History | Store and retrieve past messages |
Prerequisites
- Node.js 14+ - Installed on your system
- ws - Install with
npm install ws - Basic JavaScript knowledge
WebSocket Server
Create chat/server.js - the main WebSocket server:
const WebSocket = require('ws');
const EventEmitter = require('events');
class ChatServer extends EventEmitter {
constructor(options = {}) {
super();
this.port = options.port || 8080;
this.wss = null;
this.clients = new Map();
this.rooms = new Map();
this.messageHandlers = new Map();
this._setupDefaultHandlers();
}
_setupDefaultHandlers() {
this.messageHandlers.set('chat', this._handleChat.bind(this));
this.messageHandlers.set('join', this._handleJoin.bind(this));
this.messageHandlers.set('leave', this._handleLeave.bind(this));
this.messageHandlers.set('private', this._handlePrivate.bind(this));
this.messageHandlers.set('typing', this._handleTyping.bind(this));
this.messageHandlers.set('auth', this._handleAuth.bind(this));
}
start() {
this.wss = new WebSocket.Server({ port: this.port });
this.wss.on('connection', (ws, req) => {
this._handleConnection(ws, req);
});
this.wss.on('error', (error) => {
this.emit('error', error);
});
console.log(`Chat server running on port ${this.port}`);
return this;
}
_handleConnection(ws, req) {
const clientId = this._generateId();
ws.clientId = clientId;
ws.isAlive = true;
ws.rooms = new Set();
ws.userData = null;
this.clients.set(clientId, ws);
this.emit('connection', { clientId, ws });
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('message', (data) => {
this._handleMessage(ws, data);
});
ws.on('close', () => {
this._handleDisconnect(ws);
});
ws.on('error', (error) => {
this.emit('clientError', { clientId, error });
});
this._send(ws, {
type: 'connected',
data: { clientId }
});
}
_handleMessage(ws, data) {
try {
const message = JSON.parse(data);
const { type, data: messageData } = message;
this.emit('message', { clientId: ws.clientId, type, data: messageData });
const handler = this.messageHandlers.get(type);
if (handler) {
handler(ws, messageData);
} else {
this._send(ws, {
type: 'error',
data: { message: `Unknown message type: ${type}` }
});
}
} catch (error) {
this._send(ws, {
type: 'error',
data: { message: 'Invalid message format' }
});
}
}
_handleChat(ws, data) {
const { roomId, content } = data;
if (!roomId || !content) {
return this._send(ws, { type: 'error', data: { message: 'Missing roomId or content' } });
}
if (!ws.rooms.has(roomId)) {
return this._send(ws, { type: 'error', data: { message: 'Not in room' } });
}
const message = {
id: this._generateId(),
roomId,
senderId: ws.clientId,
senderName: ws.userData?.username || 'Anonymous',
content,
timestamp: new Date().toISOString()
};
this._broadcastToRoom(roomId, { type: 'message', data: message }, ws);
this._saveMessage(message);
this.emit('chatMessage', message);
}
_handleJoin(ws, data) {
const { roomId, username } = data;
if (!roomId) {
return this._send(ws, { type: 'error', data: { message: 'Missing roomId' } });
}
if (!this.rooms.has(roomId)) {
this._createRoom(roomId);
}
ws.rooms.add(roomId);
const history = this._getRoomHistory(roomId);
this._send(ws, {
type: 'joined',
data: { roomId, history, users: this._getRoomUsers(roomId) }
});
this._broadcastToRoom(roomId, {
type: 'userJoined',
data: {
userId: ws.clientId,
username: ws.userData?.username || 'Anonymous'
}
}, ws);
}
_handleLeave(ws, data) {
const { roomId } = data;
if (!roomId || !ws.rooms.has(roomId)) {
return;
}
ws.rooms.delete(roomId);
this._broadcastToRoom(roomId, {
type: 'userLeft',
data: {
userId: ws.clientId,
username: ws.userData?.username || 'Anonymous'
}
}, ws);
this._send(ws, {
type: 'left',
data: { roomId }
});
if (this._getRoomUsers(roomId).length === 0) {
this.rooms.delete(roomId);
}
}
_handlePrivate(ws, data) {
const { targetUserId, content } = data;
if (!targetUserId || !content) {
return this._send(ws, { type: 'error', data: { message: 'Missing targetUserId or content' } });
}
const targetWs = this.clients.get(targetUserId);
if (!targetWs) {
return this._send(ws, { type: 'error', data: { message: 'User not found' } });
}
const message = {
id: this._generateId(),
senderId: ws.clientId,
senderName: ws.userData?.username || 'Anonymous',
content,
timestamp: new Date().toISOString()
};
this._send(targetWs, {
type: 'privateMessage',
data: message
});
this._send(ws, {
type: 'privateMessageSent',
data: message
});
}
_handleTyping(ws, data) {
const { roomId, isTyping } = data;
if (roomId && ws.rooms.has(roomId)) {
this._broadcastToRoom(roomId, {
type: 'typing',
data: {
userId: ws.clientId,
username: ws.userData?.username || 'Anonymous',
isTyping
}
}, ws);
}
}
_handleAuth(ws, data) {
const { username } = data;
if (!username) {
return this._send(ws, { type: 'error', data: { message: 'Username required' } });
}
ws.userData = { username };
this._send(ws, {
type: 'authenticated',
data: {
clientId: ws.clientId,
username,
onlineUsers: this._getOnlineUsers()
}
});
this._broadcast({
type: 'userOnline',
data: {
userId: ws.clientId,
username
}
}, ws);
}
_handleDisconnect(ws) {
const rooms = Array.from(ws.rooms);
for (const roomId of rooms) {
ws.rooms.delete(roomId);
this._broadcastToRoom(roomId, {
type: 'userLeft',
data: {
userId: ws.clientId,
username: ws.userData?.username || 'Anonymous'
}
});
}
if (ws.userData) {
this._broadcast({
type: 'userOffline',
data: {
userId: ws.clientId,
username: ws.userData.username
}
});
}
this.clients.delete(ws.clientId);
this.emit('disconnect', { clientId: ws.clientId });
}
_createRoom(roomId) {
this.rooms.set(roomId, {
id: roomId,
users: new Set(),
messages: [],
createdAt: new Date().toISOString()
});
}
_broadcastToRoom(roomId, message, excludeWs = null) {
for (const [clientId, ws] of this.clients) {
if (ws.rooms.has(roomId) && ws !== excludeWs && ws.readyState === WebSocket.OPEN) {
this._send(ws, message);
}
}
}
_broadcast(message, excludeWs = null) {
for (const ws of this.clients.values()) {
if (ws !== excludeWs && ws.readyState === WebSocket.OPEN) {
this._send(ws, message);
}
}
}
_send(ws, message) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
_getRoomUsers(roomId) {
const users = [];
for (const [clientId, ws] of this.clients) {
if (ws.rooms.has(roomId)) {
users.push({
userId: clientId,
username: ws.userData?.username || 'Anonymous'
});
}
}
return users;
}
_getOnlineUsers() {
const users = [];
for (const [clientId, ws] of this.clients) {
if (ws.userData) {
users.push({
userId: clientId,
username: ws.userData.username
});
}
}
return users;
}
_getRoomHistory(roomId) {
const room = this.rooms.get(roomId);
return room ? room.messages.slice(-50) : [];
}
_saveMessage(message) {
const room = this.rooms.get(message.roomId);
if (room) {
room.messages.push(message);
if (room.messages.length > 100) {
room.messages = room.messages.slice(-100);
}
}
}
_generateId() {
return Math.random().toString(36).substring(2, 15);
}
stop() {
if (this.wss) {
this.wss.close();
}
}
}
module.exports = ChatServer;
Message Types
All messages follow a consistent format:
{
"type": "chat",
"data": {
"roomId": "general",
"content": "Hello, world!"
}
}
Available message types:
| Type | Data | Description |
|---|---|---|
| auth | { username } | Authenticate user |
| join | { roomId } | Join a room |
| leave | { roomId } | Leave a room |
| chat | { roomId, content } | Send message to room |
| private | { targetUserId, content } | Send private message |
| typing | { roomId, isTyping } | Typing indicator |
Response Messages
{
"type": "message",
"data": {
"id": "abc123",
"roomId": "general",
"senderId": "user1",
"senderName": "John",
"content": "Hello!",
"timestamp": "2024-01-01T12:00:00.000Z"
}
}
Room Management
The server handles room creation, joining, and leaving automatically. Users can create rooms by simply joining them:
// Client sends:
{ "type": "join", "data": { "roomId": "my-custom-room" } }
// Server responds:
{
"type": "joined",
"data": {
"roomId": "my-custom-room",
"history": [...],
"users": [...]
}
}
Room Events
- joined - Confirmation of joining
- left - Confirmation of leaving
- message - New message in room
- userJoined - Someone else joined
- userLeft - Someone left
- typing - Typing indicator
Authentication
Basic username-based authentication:
// Client sends:
{
"type": "auth",
"data": {
"username": "john_doe"
}
}
// Server responds:
{
"type": "authenticated",
"data": {
"clientId": "abc123",
"username": "john_doe",
"onlineUsers": [
{ "userId": "xyz789", "username": "jane" }
]
}
}
Presence updates:
// When a user comes online:
{
"type": "userOnline",
"data": {
"userId": "abc123",
"username": "john_doe"
}
}
// When a user goes offline:
{
"type": "userOffline",
"data": {
"userId": "abc123",
"username": "john_doe"
}
}
Client Implementation
Create a simple HTML client:
<!DOCTYPE html>
<html>
<head>
<title>Chat Client</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 20px auto; }
#messages { height: 400px; overflow-y: scroll; border: 1px solid #ccc; padding: 10px; }
#typing { color: #888; font-style: italic; height: 20px; }
.message { margin: 5px 0; }
.system { color: #888; }
.private { color: #00a; }
</style>
</head>
<body>
<div id="login">
<input id="username" placeholder="Enter username">
<button onclick="auth()">Join Chat</button>
</div>
<div id="chat" style="display:none">
<div id="users"></div>
<div id="rooms">
<input id="roomInput" placeholder="Room name">
<button onclick="joinRoom()">Join Room</button>
</div>
<div id="messages"></div>
<input id="messageInput" placeholder="Type a message">
<button onclick="sendMessage()">Send</button>
<div id="typing"></div>
</div>
<script>
const ws = new WebSocket('ws://localhost:8080');
let currentRoom = null;
let username = null;
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
handleMessage(msg);
};
function auth() {
username = document.getElementById('username').value;
ws.send(JSON.stringify({ type: 'auth', data: { username } }));
}
function joinRoom() {
const roomId = document.getElementById('roomInput').value;
ws.send(JSON.stringify({ type: 'join', data: { roomId, username } }));
currentRoom = roomId;
}
function sendMessage() {
const content = document.getElementById('messageInput').value;
ws.send(JSON.stringify({
type: 'chat',
data: { roomId: currentRoom, content }
}));
document.getElementById('messageInput').value = '';
}
function handleMessage(msg) {
const messages = document.getElementById('messages');
switch(msg.type) {
case 'connected':
console.log('Connected:', msg.data.clientId);
break;
case 'authenticated':
document.getElementById('login').style.display = 'none';
document.getElementById('chat').style.display = 'block';
break;
case 'joined':
messages.innerHTML = '';
msg.data.history.forEach(m => displayMessage(m));
break;
case 'message':
displayMessage(msg.data);
break;
case 'typing':
document.getElementById('typing').textContent =
msg.data.isTyping ? `${msg.data.username} is typing...` : '';
break;
}
}
function displayMessage(msg) {
const messages = document.getElementById('messages');
messages.innerHTML += `<div class="message">
<strong>${msg.senderName}</strong>: ${msg.content}
</div>`;
messages.scrollTop = messages.scrollHeight;
}
</script>
</body>
</html>
Testing the Chat Server
Run the server and test:
# Terminal 1: Start the chat server
node chat/server.js
# Terminal 2: Open multiple browser tabs
# Navigate to the client HTML file
# Log in with different usernames
# Join rooms and send messages
Test scenarios:
- Open two browser windows with different usernames
- Join the same room and see messages appear in both
- Use private messaging
- See typing indicators
- Watch presence updates when users join/leave
Summary
Congratulations! You've built a complete WebSocket chat server. Here's what you learned:
- WebSocket Server - How to create and handle WebSocket connections
- Message Handling - How to process different message types
- Room Management - How to create and manage chat rooms
- Private Messaging - How to send direct messages
- Presence - How to track online/offline users
- Typing Indicators - How to implement real-time typing
Possible Extensions
- Add message encryption
- Implement file/image sharing
- Add message reactions
- Implement message threads
- Add room moderation (kick, ban)
- Implement message search