← Back to Tutorials
Node.js

Build a WebSocket Chat Server

Difficulty: Intermediate Est. Time: ~4 hours

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.

What You'll Build
  • A WebSocket server with connection handling
  • Chat rooms with join/leave functionality
  • Private messaging between users
  • User presence tracking
  • Basic authentication
  • Message history
What You'll Learn
  • 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