Updated Nov 5, 20265 min read

How to Build a WebSocket Server in Node.js

A working Node.js WebSocket server with the ws library, heartbeats and broadcasting, then the same chat as a typed Encore streaming endpoint you can run locally and deploy to your own AWS or GCP account.

Node.js is a natural fit for WebSocket servers, since its event loop holds thousands of mostly idle connections cheaply. It does not ship a WebSocket server, though: the global WebSocket class, stable since Node.js 22.4, is a client, so the server side comes from a library or from your backend.

This guide builds a small chat server two ways: with Encore (a tool that works alongside your framework), where a WebSocket is a typed endpoint in a TypeScript backend, and with the ws library on a plain Node.js HTTP server, including the heartbeat and broadcast code a production server needs.

Quick start with Encore

Encore is a tool that works alongside your framework for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. Its streaming APIs are endpoints whose transport is a WebSocket: api.streamInOut for two-way messages, api.streamOut for server push and api.streamIn for uploads. Install the CLI with the command for your operating system below, then create an app and add a chat service:

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create chat --example=ts/hello-world$ cd chat && mkdir chat

The endpoint keeps one set of connections per room and broadcasts each message to everyone in it:

// chat/encore.service.ts import { Service } from "encore.dev/service"; export default new Service("chat");
// chat/chat.ts import { api, StreamInOut } from "encore.dev/api"; import log from "encore.dev/log"; interface Handshake { room: string; // ?room=general on the connection URL name: string; } interface ChatMessage { name: string; text: string; } type Conn = StreamInOut<ChatMessage, ChatMessage>; const rooms = new Map<string, Set<Conn>>(); export const chat = api.streamInOut<Handshake, ChatMessage, ChatMessage>( { expose: true, path: "/chat" }, async ({ room, name }, stream) => { const members = rooms.get(room) ?? new Set<Conn>(); members.add(stream); rooms.set(room, members); try { for await (const msg of stream) { const text = msg.text.slice(0, 2000); for (const peer of members) { try { await peer.send({ name, text }); } catch { members.delete(peer); // the peer disconnected } } } } catch (err) { log.warn("stream closed with error", { room, err: String(err) }); } finally { members.delete(stream); if (members.size === 0) rooms.delete(room); } }, );

encore run starts the service on localhost:4000, and the local development dashboard lists the endpoint with its message types. The generated TypeScript client exposes the stream as an async iterator, so a frontend connects with client.chat.chat({ room: "general", name: "ana" }), calls send() and reads typed ChatMessage objects in a for await loop. Add auth: true to run the connection through your auth handler like any other endpoint.

An agent asked to add realtime to the app writes one more endpoint in the same style as the REST ones, runs it locally with encore run and checks the messages in the trace view, all within the same change.

When you deploy, Encore builds the service and runs it on Fargate or EKS on AWS, or Cloud Run or GKE on GCP, in your own account, behind a load balancer and inside a VPC it provisions. If the chat needs history, add a Postgres database to the same service and store each message before broadcasting it, so clients that reconnect can load what they missed.

Without Encore: the ws library

ws is the most widely used WebSocket server for Node.js. Attach it to an HTTP server so the same port can serve health checks and upgrade requests:

// server.ts (npm install ws @types/ws) import { createServer } from "node:http"; import { WebSocketServer, WebSocket } from "ws"; const server = createServer((req, res) => { res.writeHead(200).end("ok"); // health check }); const wss = new WebSocketServer({ server, maxPayload: 64 * 1024 }); const alive = new WeakMap<WebSocket, boolean>(); wss.on("connection", (ws, req) => { alive.set(ws, true); ws.on("pong", () => alive.set(ws, true)); ws.on("error", console.error); ws.on("message", (data, isBinary) => { // Broadcast to every other open client. for (const client of wss.clients) { if (client !== ws && client.readyState === WebSocket.OPEN) { client.send(data, { binary: isBinary }); } } }); }); // Heartbeat: drop clients that did not answer the last ping. const interval = setInterval(() => { for (const ws of wss.clients) { if (!alive.get(ws)) { ws.terminate(); continue; } alive.set(ws, false); ws.ping(); } }, 30_000); wss.on("close", () => clearInterval(interval)); server.listen(8080);

The heartbeat follows the pattern from the ws README and does two jobs: it detects clients whose network vanished without a close frame, and it keeps the connection busy enough that a proxy does not cut it. An AWS Application Load Balancer, for example, closes connections after an idle timeout of 60 seconds by default. maxPayload caps message size so one client cannot send a huge frame.

Authentication belongs in the upgrade step, and since browsers cannot set an Authorization header on a WebSocket, the usual options are a session cookie or a short-lived token in the query string, verified in the server.on("upgrade") handler with noServer: true before calling wss.handleUpgrade. Libraries like Socket.IO add rooms, acknowledgements and automatic reconnects on top, at the cost of a custom protocol that plain WebSocket clients cannot speak.

Good to know

A Node.js process holds its connections in memory, so once you run two instances, a message received by one only reaches clients connected to that instance. Broadcasting across instances needs a shared channel such as Redis pub/sub, and clients need to reconnect when an instance is replaced during a deploy. Both are easier to design in before the second instance than after users report missing messages.

How to choose

Use ws directly when you want full control over the protocol on a single Node.js server, or when you are adding WebSockets to an existing HTTP server. Use Socket.IO when you need rooms and fallbacks and control both client and server.

Use Encore when WebSockets are one part of a TypeScript backend that also has REST endpoints, databases and queues, and you want typed streams, auth through the same handler and local testing with encore run, deployed to your own AWS or GCP account.

Frequently asked questions

Does Node.js have a built-in WebSocket server?

The global WebSocket class, stable since Node.js 22.4, is only a browser-compatible client, and servers use a library such as ws.

How do I keep a Node.js WebSocket connection alive behind a load balancer?

Send a ping from the server on an interval shorter than the load balancer's idle timeout and terminate clients that do not answer with a pong. An AWS Application Load Balancer closes idle connections after 60 seconds by default, so a 30-second heartbeat is a common choice.

How do I create a WebSocket endpoint with Encore?

Define it with api.streamInOut from encore.dev/api, passing a handshake type and the incoming and outgoing message types. The handler receives the stream as an async iterator for incoming messages and calls stream.send() to reply. encore run serves it locally on port 4000.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders