WebSockets & Real-Time Apps

Building a WebSocket Server with ws

20 min Lesson 4 of 35

Introduction to the ws Library

The ws library is a fast, standards-compliant WebSocket implementation for Node.js. It's one of the most popular WebSocket libraries with minimal overhead and excellent performance characteristics.

Why ws?
  • Lightweight: No external dependencies, minimal abstraction
  • Fast: Highly optimized for performance
  • Standards-compliant: Fully implements RFC 6455
  • Production-ready: Used by thousands of production applications
  • Active maintenance: Regularly updated and well-supported

Installing ws

Install the ws library using npm or yarn:

# Using npm npm install ws # Using yarn yarn add ws # Check installed version npm list ws

Creating a Basic WebSocket Server

Let's create a simple WebSocket server that echoes messages back to clients:

// server.js const WebSocket = require('ws'); // Create WebSocket server on port 8080 const wss = new WebSocket.Server({ port: 8080 }); console.log('WebSocket server started on ws://localhost:8080'); // Listen for connections wss.on('connection', (ws) => { console.log('New client connected'); // Listen for messages from this client ws.on('message', (message) => { console.log('Received:', message.toString()); // Echo the message back ws.send(`Echo: ${message.toString()}`); }); // Send welcome message ws.send('Welcome to the WebSocket server!'); });

Run the server:

node server.js

WebSocket Server Options

The WebSocket.Server constructor accepts various configuration options:

const wss = new WebSocket.Server({ port: 8080, // Server port host: '0.0.0.0', // Host to bind to (0.0.0.0 for all interfaces) path: '/socket', // URL path (e.g., ws://host:port/socket) perMessageDeflate: true, // Enable compression clientTracking: true, // Track connected clients (default: true) maxPayload: 100 * 1024 * 1024, // Max message size (100MB) verifyClient: (info, cb) => { // Custom verification function // Verify client before accepting connection const token = new URL(info.req.url, 'http://localhost').searchParams.get('token'); if (token === 'valid-token') { cb(true); // Accept connection } else { cb(false, 401, 'Unauthorized'); // Reject connection } } });
Custom Verification: Use the verifyClient option to implement authentication, rate limiting, or IP filtering before accepting WebSocket connections. This prevents unauthorized clients from establishing connections.

Attaching to an Existing HTTP Server

Instead of creating a standalone server, you can attach the WebSocket server to an existing HTTP/HTTPS server:

const http = require('http'); const WebSocket = require('ws'); const express = require('express'); // Create Express app const app = express(); // Regular HTTP routes app.get('/', (req, res) => { res.send('HTTP server is running'); }); app.get('/api/data', (req, res) => { res.json({ message: 'REST API endpoint' }); }); // Create HTTP server const server = http.createServer(app); // Attach WebSocket server to HTTP server const wss = new WebSocket.Server({ server, path: '/socket' }); wss.on('connection', (ws) => { console.log('WebSocket client connected'); ws.on('message', (message) => { console.log('Received:', message.toString()); }); }); // Start the server server.listen(8080, () => { console.log('Server listening on http://localhost:8080'); console.log('WebSocket available at ws://localhost:8080/socket'); });
Shared Port Benefits: Running WebSocket and HTTP on the same port simplifies deployment, works better with firewalls and load balancers, and allows you to share SSL/TLS certificates.

Handling Client Connections

The connection event provides access to the WebSocket instance for each connected client:

wss.on('connection', (ws, request) => { // ws: WebSocket instance for this connection // request: HTTP upgrade request object // Access request information console.log('Client IP:', request.socket.remoteAddress); console.log('User-Agent:', request.headers['user-agent']); console.log('URL:', request.url); // Parse query parameters const url = new URL(request.url, `http://${request.headers.host}`); const userId = url.searchParams.get('userId'); const token = url.searchParams.get('token'); console.log('User ID:', userId); console.log('Token:', token); // Store custom properties on the WebSocket instance ws.userId = userId; ws.isAuthenticated = token === 'valid-token'; // Send personalized welcome message ws.send(JSON.stringify({ type: 'welcome', message: `Hello user ${userId}!`, timestamp: Date.now() })); });

Receiving and Sending Messages

Receiving Messages

ws.on('message', (data, isBinary) => { // data: Buffer or string // isBinary: boolean indicating if message is binary if (isBinary) { console.log('Received binary data:', data.length, 'bytes'); // Process binary data const view = new Uint8Array(data); console.log('First byte:', view[0]); } else { // Text message const message = data.toString(); console.log('Received text:', message); // Parse JSON if applicable try { const json = JSON.parse(message); console.log('Parsed JSON:', json); // Handle different message types switch (json.type) { case 'chat': handleChatMessage(ws, json); break; case 'ping': ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() })); break; default: console.log('Unknown message type:', json.type); } } catch (e) { console.log('Not JSON message'); } } });

Sending Messages

// Send text message ws.send('Hello client!'); // Send JSON ws.send(JSON.stringify({ type: 'notification', message: 'New update' })); // Send binary data const buffer = Buffer.from([1, 2, 3, 4, 5]); ws.send(buffer); // Send with callback ws.send('Important message', (error) => { if (error) { console.error('Failed to send:', error); } else { console.log('Message sent successfully'); } }); // Send with options ws.send('Compressed message', { compress: true, // Compress this message binary: false, // Send as text frame fin: true // Final fragment }, (error) => { if (error) console.error('Send error:', error); });

Broadcasting Messages

Broadcasting sends a message to all connected clients:

// Simple broadcast to all clients function broadcast(message) { wss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(message); } }); } // Broadcast except sender function broadcastExcept(message, sender) { wss.clients.forEach((client) => { if (client !== sender && client.readyState === WebSocket.OPEN) { client.send(message); } }); } // Broadcast to specific users function broadcastToUsers(message, userIds) { wss.clients.forEach((client) => { if (userIds.includes(client.userId) && client.readyState === WebSocket.OPEN) { client.send(message); } }); } // Usage in message handler ws.on('message', (data) => { const message = data.toString(); // Broadcast to all clients except sender broadcastExcept(JSON.stringify({ type: 'chat', from: ws.userId, message: message, timestamp: Date.now() }), ws); });
Performance Consideration: Broadcasting to thousands of clients synchronously can block the event loop. For large-scale applications, consider using workers, clustering, or message queues (Redis Pub/Sub) to distribute the load.

Handling Errors

Always handle errors to prevent server crashes:

// Connection-level error handling ws.on('error', (error) => { console.error('WebSocket error:', error); // Log error details console.error('Error code:', error.code); console.error('Error message:', error.message); // Don't try to send on errored connection // The connection will be closed automatically }); // Server-level error handling wss.on('error', (error) => { console.error('WebSocket Server error:', error); // Handle server errors (port already in use, etc.) if (error.code === 'EADDRINUSE') { console.error('Port is already in use'); process.exit(1); } }); // Handle uncaught exceptions process.on('uncaughtException', (error) => { console.error('Uncaught exception:', error); // Gracefully shutdown wss.close(() => { process.exit(1); }); });

Connection Lifecycle Events

ws.on('open', () => { console.log('Connection opened'); // Note: This event is not emitted on the server side // Only the 'connection' event on wss indicates a new connection }); ws.on('close', (code, reason) => { console.log('Connection closed'); console.log('Close code:', code); console.log('Close reason:', reason.toString()); // Clean up resources if (ws.userId) { console.log(`User ${ws.userId} disconnected`); // Remove from active users list, etc. } }); ws.on('ping', (data) => { console.log('Received ping from client'); // Pong is sent automatically by ws library }); ws.on('pong', (data) => { console.log('Received pong from client'); ws.isAlive = true; // Mark connection as alive });

Ping/Pong Heartbeat

Implement heartbeat mechanism to detect broken connections:

const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); // Heartbeat interval (30 seconds) const HEARTBEAT_INTERVAL = 30000; wss.on('connection', (ws) => { // Mark connection as alive ws.isAlive = true; // Reset alive flag when pong is received ws.on('pong', () => { ws.isAlive = true; }); ws.on('message', (message) => { console.log('Received:', message.toString()); }); }); // Ping all clients periodically const interval = setInterval(() => { wss.clients.forEach((ws) => { if (ws.isAlive === false) { // Connection is dead, terminate it console.log('Terminating dead connection'); return ws.terminate(); } // Mark as potentially dead ws.isAlive = false; // Send ping ws.ping(); }); }, HEARTBEAT_INTERVAL); // Clean up interval when server closes wss.on('close', () => { clearInterval(interval); });
Heartbeat Best Practice: Always implement ping/pong heartbeat in production. This detects network failures, proxy timeouts, and client crashes that wouldn't otherwise trigger the close event.

Complete Chat Server Example

const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); // Store active users const users = new Map(); console.log('Chat server started on ws://localhost:8080'); wss.on('connection', (ws, request) => { const clientIp = request.socket.remoteAddress; console.log('New connection from:', clientIp); // Initialize connection ws.isAlive = true; ws.userId = null; // Handle pong ws.on('pong', () => { ws.isAlive = true; }); // Handle messages ws.on('message', (data) => { try { const message = JSON.parse(data.toString()); switch (message.type) { case 'join': // User joins chat ws.userId = message.userId; ws.username = message.username; users.set(ws.userId, ws); // Broadcast user joined broadcast({ type: 'user-joined', userId: ws.userId, username: ws.username, timestamp: Date.now() }); // Send active users list ws.send(JSON.stringify({ type: 'users-list', users: Array.from(users.values()).map(u => ({ userId: u.userId, username: u.username })) })); break; case 'chat-message': // Broadcast chat message broadcast({ type: 'chat-message', from: ws.username, message: message.content, timestamp: Date.now() }); break; case 'typing': // Broadcast typing indicator broadcastExcept({ type: 'user-typing', username: ws.username }, ws); break; default: console.log('Unknown message type:', message.type); } } catch (error) { console.error('Error processing message:', error); ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format' })); } }); // Handle disconnection ws.on('close', () => { console.log('Client disconnected:', ws.username); if (ws.userId) { users.delete(ws.userId); // Broadcast user left broadcast({ type: 'user-left', userId: ws.userId, username: ws.username, timestamp: Date.now() }); } }); // Handle errors ws.on('error', (error) => { console.error('WebSocket error:', error); }); }); // Broadcast helper function broadcast(data) { const message = JSON.stringify(data); wss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(message); } }); } // Broadcast except sender function broadcastExcept(data, sender) { const message = JSON.stringify(data); wss.clients.forEach((client) => { if (client !== sender && client.readyState === WebSocket.OPEN) { client.send(message); } }); } // Heartbeat const interval = setInterval(() => { wss.clients.forEach((ws) => { if (ws.isAlive === false) { return ws.terminate(); } ws.isAlive = false; ws.ping(); }); }, 30000); wss.on('close', () => { clearInterval(interval); });
Exercise: Extend the chat server to include:
  • Private messaging between specific users
  • Chat rooms/channels that users can join
  • Message history stored in memory or database
  • User authentication with JWT tokens
  • Rate limiting to prevent spam

Summary

The ws library provides a robust foundation for building WebSocket servers in Node.js. Key concepts include creating servers with configuration options, handling connections and messages, broadcasting to multiple clients, implementing error handling, and using ping/pong heartbeat for connection health monitoring. With these building blocks, you can create production-ready real-time applications. In the next lesson, we'll explore Socket.io, a higher-level library that adds features like automatic reconnection and room support.