tests.ws

Node.js WebSocket Server with ws

nodejs websocket ws server real-time

The ws library is the most popular WebSocket implementation for Node.js. It is fast, spec-compliant, and has no dependencies. This guide covers server setup, client handling, broadcasting, and production configuration for building a Node.js WebSocket server.

Whether you are building a chat application, a live dashboard, or a multiplayer game backend, ws gives you a low-level, high-performance foundation. Unlike higher-level frameworks, it stays close to the WebSocket protocol and gives you full control over connection handling.

Installation

Install the ws npm package:

npm install ws

At the time of writing, the current major version is ws 8.x. The npm ws package supports Node.js 10 and above, though you should use Node.js 18 or later for long-term support and modern JavaScript features.

You can verify the installed version:

npm list ws

No additional dependencies are required. The node ws package handles both server and client roles natively.

Basic Server

Creating a WebSocket server with ws takes just a few lines. The WebSocketServer class binds to a port and emits events when clients connect.

const { WebSocketServer } = require('ws');

const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (ws, req) => {
  console.log('Client connected from', req.socket.remoteAddress);

  ws.send('Welcome to the server');

  ws.on('close', () => {
    console.log('Client disconnected');
  });
});

console.log('WebSocket server running on ws://localhost:8080');

The connection event fires for every new client. The callback receives two arguments: the ws socket instance and the original HTTP req object from the upgrade request. You can read headers, cookies, and query parameters from req.

To test your server quickly, open a browser console and run:

const socket = new WebSocket('ws://localhost:8080');
socket.onmessage = (event) => console.log(event.data);

For a more thorough approach, try a dedicated WebSocket testing tool that lets you inspect frames and connection states.

Handling Messages

The message event fires whenever a client sends data. By default, ws delivers messages as Buffer objects. Set the encoding or convert them to strings as needed.

wss.on('connection', (ws) => {
  ws.on('message', (data, isBinary) => {
    if (isBinary) {
      console.log('Received binary data:', data.length, 'bytes');
      return;
    }

    const text = data.toString();
    console.log('Received:', text);

    // Parse JSON messages
    try {
      const message = JSON.parse(text);
      handleMessage(ws, message);
    } catch (err) {
      ws.send(JSON.stringify({ error: 'Invalid JSON' }));
    }
  });
});

function handleMessage(ws, message) {
  switch (message.type) {
    case 'ping':
      ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
      break;
    case 'echo':
      ws.send(JSON.stringify({ type: 'echo', payload: message.payload }));
      break;
    default:
      ws.send(JSON.stringify({ error: 'Unknown message type' }));
  }
}

A common pattern is to define a message protocol using JSON objects with a type field. This makes it easy to route messages to the correct handler. For a deeper look at client-side WebSocket programming, see the JavaScript WebSocket guide.

Broadcasting to All Clients

Broadcasting sends a message to every connected client. The wss.clients property is a Set containing all active connections. You should check each client’s readyState before sending.

function broadcast(wss, data, excludeClient = null) {
  const message = typeof data === 'string' ? data : JSON.stringify(data);

  wss.clients.forEach((client) => {
    if (client !== excludeClient && client.readyState === 1) {
      client.send(message);
    }
  });
}

// Usage inside a connection handler
wss.on('connection', (ws) => {
  broadcast(wss, { type: 'system', text: 'A new user joined' }, ws);

  ws.on('message', (data) => {
    const text = data.toString();
    broadcast(wss, { type: 'chat', text }, ws);
  });

  ws.on('close', () => {
    broadcast(wss, { type: 'system', text: 'A user left' });
  });
});

The readyState value of 1 corresponds to WebSocket.OPEN. Checking this prevents errors when a client is in the process of closing. The optional excludeClient parameter avoids echoing a message back to the sender.

Rooms and Channels Pattern

The ws library does not include built-in room support like Socket.IO does. You can implement rooms yourself using a Map that associates room names with sets of clients.

const rooms = new Map();

function joinRoom(ws, roomName) {
  if (!rooms.has(roomName)) {
    rooms.set(roomName, new Set());
  }
  rooms.get(roomName).add(ws);
  ws.rooms = ws.rooms || new Set();
  ws.rooms.add(roomName);
}

function leaveRoom(ws, roomName) {
  const room = rooms.get(roomName);
  if (room) {
    room.delete(ws);
    if (room.size === 0) {
      rooms.delete(roomName);
    }
  }
  if (ws.rooms) {
    ws.rooms.delete(roomName);
  }
}

function leaveAllRooms(ws) {
  if (ws.rooms) {
    ws.rooms.forEach((roomName) => leaveRoom(ws, roomName));
  }
}

function broadcastToRoom(roomName, data, excludeClient = null) {
  const room = rooms.get(roomName);
  if (!room) return;

  const message = typeof data === 'string' ? data : JSON.stringify(data);
  room.forEach((client) => {
    if (client !== excludeClient && client.readyState === 1) {
      client.send(message);
    }
  });
}

Attach room metadata directly to the ws object. When a client disconnects, call leaveAllRooms(ws) in the close handler to clean up. This pattern scales well for moderate numbers of rooms. For large-scale deployments across multiple server instances, you will need a pub/sub layer like Redis. See WebSocket scalability patterns for more on that topic.

Integration with Express and HTTP Server

You often want to serve both HTTP endpoints and WebSocket connections on the same port. Pass an existing HTTP server to ws using the server option instead of port.

const express = require('express');
const { createServer } = require('http');
const { WebSocketServer } = require('ws');

const app = express();
const server = createServer(app);

app.get('/health', (req, res) => {
  res.json({ status: 'ok', connections: wss.clients.size });
});

const wss = new WebSocketServer({ server });

wss.on('connection', (ws, req) => {
  console.log('WebSocket connection on path:', req.url);
  ws.send('Connected');
});

server.listen(3000, () => {
  console.log('HTTP and WebSocket server on port 3000');
});

Path-Based Routing

If you need multiple WebSocket endpoints (for example, one for chat and one for notifications), handle the upgrade event manually:

const chatWss = new WebSocketServer({ noServer: true });
const notifyWss = new WebSocketServer({ noServer: true });

server.on('upgrade', (request, socket, head) => {
  const { pathname } = new URL(request.url, `http://${request.headers.host}`);

  if (pathname === '/ws/chat') {
    chatWss.handleUpgrade(request, socket, head, (ws) => {
      chatWss.emit('connection', ws, request);
    });
  } else if (pathname === '/ws/notifications') {
    notifyWss.handleUpgrade(request, socket, head, (ws) => {
      notifyWss.emit('connection', ws, request);
    });
  } else {
    socket.destroy();
  }
});

The noServer: true option tells ws not to bind to any port on its own. You then route upgrade requests based on the URL path and call handleUpgrade on the correct server instance.

Authentication

WebSocket connections start as HTTP upgrade requests. This is the right place to verify credentials. You have two main approaches: the verifyClient callback or manual upgrade handling.

Using verifyClient

const wss = new WebSocketServer({
  port: 8080,
  verifyClient: (info, callback) => {
    const token = new URL(info.req.url, 'http://localhost').searchParams.get('token');

    verifyToken(token)
      .then((user) => {
        info.req.user = user;
        callback(true);
      })
      .catch(() => {
        callback(false, 401, 'Unauthorized');
      });
  }
});

async function verifyToken(token) {
  // Replace with your actual token verification logic
  if (!token) throw new Error('No token');
  // Example: decode JWT, query database, etc.
  return { id: 1, name: 'Alice' };
}

wss.on('connection', (ws, req) => {
  console.log('Authenticated user:', req.user.name);
});

Manual Upgrade with Authentication

const wss = new WebSocketServer({ noServer: true });

server.on('upgrade', async (request, socket, head) => {
  try {
    const token = new URL(request.url, 'http://localhost').searchParams.get('token');
    const user = await verifyToken(token);
    request.user = user;

    wss.handleUpgrade(request, socket, head, (ws) => {
      wss.emit('connection', ws, request);
    });
  } catch (err) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
  }
});

The manual approach gives you more flexibility. You can inspect cookies, read the Authorization header, or check any other request property. For detailed security practices, read the WebSocket security guide.

Ping/Pong for Connection Health

TCP connections can silently drop without either side noticing. This is especially common on mobile networks and behind load balancers. The WebSocket protocol includes ping/pong frames specifically for detecting dead connections.

const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (ws) => {
  ws.isAlive = true;

  ws.on('pong', () => {
    ws.isAlive = true;
  });

  ws.on('close', () => {
    ws.isAlive = false;
  });
});

const interval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!ws.isAlive) {
      return ws.terminate();
    }

    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

wss.on('close', () => {
  clearInterval(interval);
});

Every 30 seconds, the server sends a ping frame to each client. If the client responds with a pong before the next interval, isAlive is reset to true. If not, the server assumes the connection is dead and terminates it. This prevents resource leaks from zombie connections.

Handling Binary Data

The ws library handles binary data natively. Clients can send ArrayBuffer or Blob objects, and ws delivers them as Buffer instances on the server.

wss.on('connection', (ws) => {
  ws.on('message', (data, isBinary) => {
    if (isBinary) {
      // data is a Buffer
      console.log('Binary message:', data.length, 'bytes');

      // Process the binary data
      const processed = processBuffer(data);

      // Send binary data back
      ws.send(processed, { binary: true });
    }
  });
});

function processBuffer(buffer) {
  // Example: prefix each message with its length as a 4-byte header
  const header = Buffer.alloc(4);
  header.writeUInt32BE(buffer.length, 0);
  return Buffer.concat([header, buffer]);
}

When sending binary data, pass { binary: true } as the second argument to ws.send(). On the client side, set socket.binaryType = 'arraybuffer' to receive ArrayBuffer instead of Blob.

Error Handling

Always attach an error event handler to both the server and individual connections. Unhandled errors will crash your Node.js process.

const wss = new WebSocketServer({ port: 8080 });

wss.on('error', (error) => {
  console.error('Server error:', error.message);
});

wss.on('connection', (ws) => {
  ws.on('error', (error) => {
    console.error('Connection error:', error.message);
  });

  ws.on('close', (code, reason) => {
    console.log(`Connection closed: ${code} ${reason.toString()}`);
  });
});

// Graceful shutdown
function shutdown() {
  console.log('Shutting down...');

  wss.clients.forEach((client) => {
    client.close(1001, 'Server shutting down');
  });

  wss.close(() => {
    console.log('Server closed');
    process.exit(0);
  });
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

Close code 1001 means “going away,” which tells clients the server is shutting down intentionally. Always give clients a chance to reconnect by sending a close frame before terminating the process.

Production Configuration

Several options help you run ws safely in production.

const wss = new WebSocketServer({
  port: 8080,

  // Maximum allowed message size in bytes (default: 100 MiB)
  maxPayload: 1024 * 1024, // 1 MiB

  // Enable per-message compression
  perMessageDeflate: {
    zlibDeflateOptions: {
      chunkSize: 1024,
      memLevel: 7,
      level: 3
    },
    zlibInflateOptions: {
      chunkSize: 10 * 1024
    },
    clientNoContextTakeover: true,
    serverNoContextTakeover: true,
    serverMaxWindowBits: 10,
    concurrencyLimit: 10,
    threshold: 1024 // Only compress messages larger than 1 KiB
  },

  // TCP backlog (number of pending connections the OS will queue)
  backlog: 512
});

maxPayload prevents clients from sending oversized messages that consume too much memory. Set this to the largest message your application actually needs.

perMessageDeflate enables WebSocket compression. It reduces bandwidth at the cost of CPU. The threshold setting avoids compressing tiny messages where the overhead is not worth it. If your server is CPU-bound, consider disabling compression entirely by setting perMessageDeflate: false.

backlog controls the TCP listen backlog. Increase this if you expect bursts of simultaneous connection attempts.

For a full graceful shutdown, combine the SIGTERM handler from the error handling section with a timeout:

process.on('SIGTERM', () => {
  wss.clients.forEach((client) => {
    client.close(1001, 'Server shutting down');
  });

  wss.close(() => {
    process.exit(0);
  });

  // Force exit after 10 seconds if connections do not close
  setTimeout(() => {
    console.error('Forcing shutdown after timeout');
    process.exit(1);
  }, 10000);
});

Full Example: Chat Server

Here is a complete chat server with rooms, broadcasting, join/leave messages, and basic error handling.

const { WebSocketServer } = require('ws');

const PORT = 8080;
const wss = new WebSocketServer({ port: PORT, maxPayload: 64 * 1024 });
const rooms = new Map();

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.username = 'Anonymous';
  ws.currentRoom = null;

  ws.on('pong', () => {
    ws.isAlive = true;
  });

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

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

    switch (message.type) {
      case 'set-name':
        ws.username = String(message.name).slice(0, 32);
        sendTo(ws, { type: 'name-set', name: ws.username });
        break;

      case 'join':
        handleJoin(ws, String(message.room));
        break;

      case 'leave':
        handleLeave(ws);
        break;

      case 'chat':
        handleChat(ws, String(message.text).slice(0, 500));
        break;

      default:
        sendTo(ws, { type: 'error', text: 'Unknown message type' });
    }
  });

  ws.on('close', () => {
    handleLeave(ws);
  });

  sendTo(ws, { type: 'welcome', text: 'Connected to chat server' });
});

function sendTo(ws, data) {
  if (ws.readyState === 1) {
    ws.send(JSON.stringify(data));
  }
}

function handleJoin(ws, roomName) {
  if (ws.currentRoom) {
    handleLeave(ws);
  }

  if (!rooms.has(roomName)) {
    rooms.set(roomName, new Set());
  }

  rooms.get(roomName).add(ws);
  ws.currentRoom = roomName;

  broadcastToRoom(roomName, {
    type: 'system',
    text: `${ws.username} joined the room`
  }, ws);

  sendTo(ws, {
    type: 'joined',
    room: roomName,
    users: rooms.get(roomName).size
  });
}

function handleLeave(ws) {
  const roomName = ws.currentRoom;
  if (!roomName) return;

  const room = rooms.get(roomName);
  if (room) {
    room.delete(ws);
    broadcastToRoom(roomName, {
      type: 'system',
      text: `${ws.username} left the room`
    });
    if (room.size === 0) {
      rooms.delete(roomName);
    }
  }

  ws.currentRoom = null;
}

function handleChat(ws, text) {
  if (!ws.currentRoom) {
    sendTo(ws, { type: 'error', text: 'Join a room first' });
    return;
  }

  if (!text.trim()) return;

  broadcastToRoom(ws.currentRoom, {
    type: 'chat',
    user: ws.username,
    text: text,
    timestamp: Date.now()
  }, ws);
}

function broadcastToRoom(roomName, data, exclude = null) {
  const room = rooms.get(roomName);
  if (!room) return;

  const payload = JSON.stringify(data);
  room.forEach((client) => {
    if (client !== exclude && client.readyState === 1) {
      client.send(payload);
    }
  });
}

// Ping/pong heartbeat
const heartbeat = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!ws.isAlive) return ws.terminate();
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

wss.on('close', () => clearInterval(heartbeat));

// Graceful shutdown
process.on('SIGTERM', () => {
  wss.clients.forEach((client) => {
    client.close(1001, 'Server shutting down');
  });
  wss.close(() => process.exit(0));
  setTimeout(() => process.exit(1), 10000);
});

console.log(`Chat server running on ws://localhost:${PORT}`);

To test this server, open multiple browser tabs and connect using the browser WebSocket API:

const ws = new WebSocket('ws://localhost:8080');
ws.onmessage = (e) => console.log(JSON.parse(e.data));
ws.onopen = () => {
  ws.send(JSON.stringify({ type: 'set-name', name: 'Alice' }));
  ws.send(JSON.stringify({ type: 'join', room: 'general' }));
};
// Send a chat message
ws.send(JSON.stringify({ type: 'chat', text: 'Hello everyone!' }));

Performance Tips

When handling high message throughput, small optimizations add up.

Pre-serialize broadcast messages. Call JSON.stringify() once and send the same string to all clients. The chat server example above already does this in broadcastToRoom.

Use binary for large payloads. JSON parsing and stringifying become expensive for messages over a few kilobytes. Consider MessagePack, Protocol Buffers, or a custom binary format for high-frequency data like game state updates.

Set connection limits. Track the number of active connections and reject new ones when you hit a threshold. This prevents a single server from being overwhelmed.

const MAX_CONNECTIONS = 10000;

wss.on('connection', (ws) => {
  if (wss.clients.size > MAX_CONNECTIONS) {
    ws.close(1013, 'Too many connections');
    return;
  }
  // ... normal handling
});

Avoid per-message allocations. Reuse buffers where possible. If you are building frame headers or length prefixes, allocate them once and rewrite the values.

Disable compression for low-latency use cases. Per-message deflate adds CPU overhead and latency. For applications like gaming where every millisecond matters, set perMessageDeflate: false.

Monitor connection counts and message rates. Export metrics to your monitoring system so you can spot connection leaks, traffic spikes, and slow clients before they become outages.

  • JavaScript WebSocket Client Guide covers the browser-side WebSocket API and reconnection strategies.
  • Socket.IO Guide explains the higher-level framework built on top of ws, with automatic reconnection and rooms.
  • WebSocket Scalability discusses horizontal scaling, sticky sessions, and pub/sub patterns for multi-server deployments.
  • WebSocket Security covers origin checking, rate limiting, input validation, and TLS configuration.
  • WebSocket Tester is an interactive tool for testing and debugging WebSocket connections.