A WebSocket server is a long-lived TCP process that keeps an open, two-way connection to each client so either side can push messages the instant they happen — no polling, no re-connecting per request.
In Node.js you can stand one up in about fifteen lines with the ws library, and this guide builds a runnable one (broadcast, then rooms and presence) below. The harder question isn't how to build one — it's whether you should run one yourself, and this guide answers that too.
That fork is the whole article. If you want to own the infrastructure, the build path is a small Node + ws server you can copy-paste and run right now.
If you'd rather not operate, scale, secure, and keep a fleet of stateful socket servers alive forever, the buy path or there are free options also available like Metered Realtime is a managed WebSocket service you connect to with one import. We build the small one first, so the managed one isn't a black box.
TL;DR: A minimal WebSocket server in Node.js is ~15 lines with
ws— we build broadcast, then rooms + presence, both runnable (and the same server in Python). The code is the easy 5%. The hard 95% is everything a toy server ignores: reconnection, auth, backpressure, horizontal scale across many boxes, and the reliability engineering behind five-nines uptime. Self-host when you need deep infrastructure control or on-prem/compliance; otherwise a managed WebSocket service is almost always the better trade once you count engineering time, maintenance, and server cost.Companion tutorial: if your goal is specifically WebRTC, the WebRTC Signaling Server guide builds a signaling relay on top of exactly this pattern — a WebSocket server is the transport most WebRTC signaling runs on.
What a WebSocket server actually is
A normal HTTP request is one round trip: the client asks, the server answers, the connection closes. That's fine for loading a page, but it's a bad fit for anything live — chat, presence, dashboards, multiplayer, notifications — because the server can't speak until it's spoken to.
A WebSocket connection is different. The client and server do a one-time HTTP "upgrade" handshake, and after that the socket stays open.
Either side can send a message at any moment, in either direction, with almost no per-message overhead. That persistent, full-duplex channel is the whole value.
It helps to see WebSockets next to the alternatives:
| Approach | Direction | Connection | Best for |
|---|---|---|---|
| Polling / long-polling | client pulls | repeated HTTP requests | simple, low-frequency updates |
| Server-Sent Events (SSE) | server → client only | one long-lived HTTP stream | one-way feeds (notifications, logs) |
| WebSocket | both directions | one persistent socket | chat, presence, multiplayer, live collaboration |
A WebSocket server, then, is the process that accepts those upgraded connections, holds one open socket per client, and decides what to do with each incoming message — usually routing it to other clients. It's stateful (it remembers who's connected) and long-lived (it doesn't return a response and forget you), which is exactly why it's more work to operate than a stateless HTTP endpoint.
Article Contents
Build a WebSocket server in Node.js (with ws)
The ws library is the de-facto WebSocket implementation for Node — small, fast, zero-fuss. Here's the smallest useful server: it accepts connections and relays every message it receives to all the other connected clients (a broadcast bus).
Create a folder, then:
npm init -y
npm pkg set type=module
npm install ws
Save this as server.js:
// server.js — the smallest useful WebSocket server: relay every message to all other clients.
import { WebSocketServer, WebSocket } from "ws";
const PORT = process.env.PORT || 8080;
const wss = new WebSocketServer({ port: PORT });
wss.on("connection", (socket) => {
console.log("client connected — total:", wss.clients.size);
socket.on("message", (data, isBinary) => {
// fan the message out to everyone except the sender
for (const client of wss.clients) {
if (client !== socket && client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
}
});
socket.on("close", () => {
console.log("client disconnected — total:", wss.clients.size);
});
});
console.log(`WebSocket server listening on ws://localhost:${PORT}`);
Run it:
node server.js
# WebSocket server listening on ws://localhost:8080
That's a working WebSocket server. Three things are worth noticing: wss.clients is a live Set of every open socket, readyState === WebSocket.OPEN guards against sockets mid-close (calling .send() on a closing socket throws), and we skip the sender so people don't see their own messages echoed back.
Add rooms, presence, and a heartbeat
A single broadcast bus is rarely what you want — real apps have rooms (chat channels, game lobbies, documents) and need to know who's present. That means a message protocol. WebSocket frames are just bytes, so a tiny JSON envelope with a type field is the standard move.
Production servers also need a heartbeat to notice sockets that died without a clean close (a common gotcha — a yanked network cable leaves a "connection" that's really gone).
Save this as rooms-server.js:
// rooms-server.js — a WebSocket server with rooms (channels), presence, a JSON protocol, and heartbeat.
import { WebSocketServer, WebSocket } from "ws";
const PORT = process.env.PORT || 8080;
const wss = new WebSocketServer({ port: PORT });
// which room each socket is in
const roomOf = new Map();
function broadcast(room, message, except) {
const payload = JSON.stringify(message);
for (const client of wss.clients) {
if (client !== except && client.readyState === WebSocket.OPEN && roomOf.get(client) === room) {
client.send(payload);
}
}
}
wss.on("connection", (socket) => {
socket.isAlive = true;
socket.on("pong", () => { socket.isAlive = true; });
socket.on("message", (raw) => {
let msg;
try {
msg = JSON.parse(raw);
} catch {
return socket.send(JSON.stringify({ type: "error", error: "invalid_json" }));
}
if (msg.type === "join") {
roomOf.set(socket, msg.room);
socket.send(JSON.stringify({ type: "joined", room: msg.room }));
broadcast(msg.room, { type: "presence", event: "peer-joined" }, socket);
} else if (msg.type === "message") {
const room = roomOf.get(socket);
if (room) broadcast(room, { type: "message", data: msg.data }, socket);
}
});
socket.on("close", () => {
const room = roomOf.get(socket);
if (room) broadcast(room, { type: "presence", event: "peer-left" }, socket);
roomOf.delete(socket);
});
});
// heartbeat: every 30s, drop any socket that didn't answer the previous ping
const heartbeat = setInterval(() => {
for (const socket of wss.clients) {
if (socket.isAlive === false) {
socket.terminate();
continue;
}
socket.isAlive = false;
socket.ping();
}
}, 30000);
wss.on("close", () => clearInterval(heartbeat));
console.log(`WebSocket rooms server listening on ws://localhost:${PORT}`);
A client sends {"type":"join","room":"lobby"} to enter a room, {"type":"message","data":"…"} to talk to it, and everyone in the room gets peer-joined / peer-left presence events automatically. The heartbeat block at the bottom pings every socket every 30 seconds and terminates any that didn't pong back since the last round — that's how you reclaim dead connections. Notice it's already fiddly, and it's just one of the hardening items we'll list shortly.
A browser client
Save this as index.html and open it in two tabs (run node rooms-server.js first):
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WebSocket chat demo</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
#log { border: 1px solid #cbd5e1; border-radius: 8px; height: 16rem; overflow-y: auto; padding: .75rem; margin-bottom: .75rem; }
#log div { padding: .1rem 0; }
form { display: flex; gap: .5rem; }
input { flex: 1; padding: .5rem; border: 1px solid #cbd5e1; border-radius: 8px; }
button { padding: .5rem 1rem; border: 0; border-radius: 8px; background: #6d5efc; color: #fff; cursor: pointer; }
</style>
</head>
<body>
<h1>WebSocket chat</h1>
<div id="log"></div>
<form id="form">
<input id="input" autocomplete="off" placeholder="Type a message…" />
<button type="submit">Send</button>
</form>
<script>
const log = (line) => {
const el = document.getElementById("log");
el.append(Object.assign(document.createElement("div"), { textContent: line }));
el.scrollTop = el.scrollHeight;
};
const ws = new WebSocket("ws://localhost:8080");
ws.addEventListener("open", () => {
log("· connected");
ws.send(JSON.stringify({ type: "join", room: "lobby" }));
});
ws.addEventListener("message", (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "joined") log("· joined room: " + msg.room);
else if (msg.type === "presence") log("· " + msg.event);
else if (msg.type === "message") log("peer: " + msg.data);
});
ws.addEventListener("close", () => log("· disconnected"));
document.getElementById("form").addEventListener("submit", (e) => {
e.preventDefault();
const input = document.getElementById("input");
const text = input.value.trim();
if (!text) return;
ws.send(JSON.stringify({ type: "message", data: text }));
log("you: " + text);
input.value = "";
});
</script>
</body>
</html>
Type in one tab and it appears in the other. Open a third tab to watch peer-joined fire. You now have a real, working WebSocket server with rooms and presence — the complete, runnable code is linked at the end.
The same server in Python
The protocol is language-agnostic, so the build looks the same in any stack. Here's the broadcast server again as a python websocket server, using the websockets library. Save it as server.py:
# server.py — a minimal WebSocket broadcast server in Python (websockets library).
import asyncio
from websockets.asyncio.server import serve
clients = set()
async def handler(socket):
clients.add(socket)
try:
async for message in socket:
# fan the message out to everyone except the sender
for client in clients:
if client is not socket:
await client.send(message)
finally:
clients.discard(socket)
async def main():
async with serve(handler, "localhost", 8080):
print("WebSocket server listening on ws://localhost:8080")
await asyncio.get_running_loop().create_future() # run forever
asyncio.run(main())
pip install websockets
python server.py
Same shape — accept a socket, hold it, relay to everyone else. Go (gorilla/websocket) and Java (Spring's @ServerEndpoint) follow the identical pattern. What changes below is not the language; it's the operational load.
What this toy server ignores (the hard 95%)
The code above is complete and correct — and it is nowhere near production. The forty lines are the easy part. Here's the part that isn't, and it's the same list in every language:
- Reconnection. Networks drop. Laptops sleep, phones roam Wi-Fi→cellular, load balancers cut idle sockets. A real client needs exponential backoff with jitter; a real server needs to not fall over when thousands reconnect at once (the "thundering herd").
-
Authentication & authorization. Who is this socket? Which rooms may it join? Our server trusts anyone who connects. Production needs token auth at the handshake, per-room permission checks on every message, and an
Origin-header check at the upgrade — WebSockets aren't covered by the browser's same-origin policy, so without it any site can open a cross-site socket to your server (CSWSH). -
Backpressure. A slow client whose send buffer fills up will balloon your server's memory. You have to watch
socket.bufferedAmountand shed or disconnect. A toy server just OOMs. -
Horizontal scale. One process holds sockets in one
Mapin one box's memory. The moment you need a second box, "who's inlobby?" spans machines — now you need a shared pub/sub backplane (Redis, NATS) so servers can reach clients they don't personally hold. - TLS, presence at scale, message limits, idle timeouts, metrics, graceful deploys — every one is a project.
- Reliability. This is the quiet giant. Committing to — and actually delivering — five-nines (99.999%) uptime is roughly five minutes of downtime per year, across deploys, cloud incidents, and traffic spikes. That is genuinely hard engineering, and it never ships as a weekend project.
Build vs buy: the honest decision
So should you run your own WebSocket server or use a managed one? Here's the straight version, no hedging.
Run it yourself when you genuinely need it: deep infrastructure control (custom protocols, exotic routing, data that legally cannot leave your network), or a compliance/corporate mandate for on-premise deployment. Those are real reasons, and self-hosting is the right call there.
In every other case, a managed WebSocket service is usually the better trade — because the forty lines were never the cost. Getting from the toy above to something you'd trust in production — auth, reconnection, backpressure, a pub/sub backplane for multiple boxes, metrics, and deploys that don't drop every connection — is realistically a few engineer-weeks up front, then a permanent line item of maintenance and on-call, plus the server bill for boxes sized to your peak concurrency. Weighed against a service with a free tier, the math rarely favors DIY.
| Factor | Self-hosted ws
|
Managed WebSocket service |
|---|---|---|
| Time to first message | Minutes (the code above) | Minutes (an import + a key) |
| Reconnection logic | You build & tune it | Built in |
| Auth / permissions | You build it | Built in (keys / tokens) |
| Horizontal scale | You add a pub/sub backplane | Handled for you |
| Reliability / uptime | Your on-call rotation | The provider's problem |
| Ongoing cost | Eng-weeks + maintenance + VM/infra | Usage-based (often a free tier) |
| Best when | You need infra control or on-prem | You want to ship the product, not the plumbing |
The Free managed option: Metered Realtime Messaging
If "buy" is the right side of that table for you, Metered Realtime Messaging is a managed, high-availability WebSocket service that hands you rooms, presence, direct messages, and auth over the same JSON-over-WebSocket model we just built — minus the operations.
It's deliberately raw JSON over WebSocket, not Socket.io (so clients on any stack — browser, Node, Go, Python, Swift — can speak the wire protocol), and the browser SDK is MIT-licensed, zero-dependency, and ~13 KB gzipped. Here's the rooms-and-presence app from above, as a client against the managed service — no server for you to run:
So here's the payoff. This is the entire managed version of the chat app we just built — same UI, same room, same presence. Notice what's missing: there is no server file.
Save this as metered-chat.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WebSocket chat demo — managed (Metered Realtime)</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
#log { border: 1px solid #cbd5e1; border-radius: 8px; height: 16rem; overflow-y: auto; padding: .75rem; margin-bottom: .75rem; }
#log div { padding: .1rem 0; }
form { display: flex; gap: .5rem; }
input { flex: 1; padding: .5rem; border: 1px solid #cbd5e1; border-radius: 8px; }
button { padding: .5rem 1rem; border: 0; border-radius: 8px; background: #6d5efc; color: #fff; cursor: pointer; }
</style>
</head>
<body>
<h1>WebSocket chat — managed</h1>
<div id="log"></div>
<form id="form">
<input id="input" autocomplete="off" placeholder="Type a message…" />
<button type="submit">Send</button>
</form>
<script type="module">
import { SignallingClient } from "https://esm.sh/@metered-ca/realtime@1.1.0";
const log = (line) => {
const el = document.getElementById("log");
el.append(Object.assign(document.createElement("div"), { textContent: line }));
el.scrollTop = el.scrollHeight;
};
// Publishable key — safe to use in the browser. Get yours free (no card):
// https://www.metered.ca/docs/realtime-messaging/
const client = new SignallingClient({ apiKey: "pk_live_YOUR_KEY" });
await client.connect();
log("· connected");
client.on("message", ({ channel, from, data }) => {
log(`peer: ${data.text}`);
});
client.on("presence", ({ channel, joined, left }) => {
for (const peer of joined) log("· peer-joined");
for (const peer of left) log("· peer-left");
});
await client.subscribe("lobby");
log("· joined room: lobby");
document.getElementById("form").addEventListener("submit", async (e) => {
e.preventDefault();
const input = document.getElementById("input");
const text = input.value.trim();
if (!text) return;
await client.publish("lobby", { text });
log("you: " + text);
input.value = "";
});
</script>
</body>
</html>
Serve the folder with any static server, open the page in two tabs, and type. rooms-server.js, the heartbeat, the nginx block, the TLS certificates — none of it exists on your side anymore.
This isn't hypothetical: we ran exactly this file against the live service while writing this guide. Two Chrome tabs connected, presence fired in both directions, and messages round-tripped A→B and B→A — with no echo to the sender, the same semantics our DIY server had.
Here's what each path leaves in your repo — and on your pager:
| Build (self-host) | Free (managed) | |
|---|---|---|
| Files you ship |
rooms-server.js + index.html + nginx config |
metered-chat.html |
| Server process | Yours, running 24/7 | None on your side |
TLS / wss://
|
Reverse proxy + certificates | Included |
| Heartbeat / dead sockets | You wrote it (and maintain it) | Built into the service + SDK |
| Reconnection | Still on your to-do list | Automatic — exponential backoff + jitter |
| Who gets paged | You | The provider |
(Comparison as of 2026-07-13.)
The endpoint is wss://rms.metered.ca/v1; the pk_live_ key is publishable, so a no-backend prototype needs nothing else. For production you mint short-lived JWTs from a secret key on your server. On the performance question that matters for a relay, typical message-relay latency on Metered's global service is p50 ≈ 5 ms and p99 ≈ 20 ms, with a usable connection established in roughly 100–200 ms.
And the reliability half of the trade: Metered Realtime's uptime has historically held at five-nines (99.999%).
One nice touch if you're actually doing WebRTC: the same connection can carry your signaling and auto-deliver Metered TURN credentials in the connection welcome, so you don't wire up a separate TURN-credential fetch. That's a signaling-specific detail covered in the WebRTC signaling guide.
The hard 95%, handled
Earlier we listed everything the toy server ignores. Here's where each of those items lands on the managed path — this table is the real substance of the "buy" decision, so it's worth being specific.
| Toy-server gap (from the iceberg) | On Metered Realtime |
|---|---|
| Reconnection + thundering herd | The SDK reconnects for you: exponential backoff with jitter, tunable via ReconnectOptions, and close-code-aware — it won't hammer the service after a terminal error |
| Auth & permissions | Publishable key for prototypes; production mints short-lived JWTs that scope exactly which channels a client may touch (wildcard patterns) and what it may do (publish / subscribe / presence / send) |
| Backpressure & abuse | Per-connection token bucket (100 msg/s sustained, 200 burst), 64 KB frame cap, per-IP connection limits |
| Dead-socket detection | Application-level keepalive + inactivity timeout built into the SDK (default 60 s) |
| TLS |
wss:// only — nothing to configure |
| Multi-box scale + uptime | The provider's job — the historical five-nines figure above is what that engineering buys |
Two of those deserve a sentence each. The JWT model is the production path: your backend signs a short-lived token that binds a user to a stable peer ID, their allowed channels, and their permissions — and the SDK refreshes it automatically on every reconnect.
And the limits aren't fine print — they're the abuse story. The free tier's limits are large and exist to stop misuse, not to squeeze prototypes into an upgrade.
What developers run on it
One connection, one protocol — but teams point it at very different workloads. These are the three we see most.
Live chat & presence. Subscribe with includeSenderMetadata: true and every message arrives stamped with the sender's verified identity — chat bubbles without a user-lookup round trip. The roster is just the presence events you already saw, and moderation is one REST call from your backend to force-disconnect a peer.
await client.subscribe("room-42", { includeSenderMetadata: true });
client.on("message", ({ from, fromMetadata, data }) => {
addBubble(fromMetadata?.username ?? from, data.text);
});
AI agents. Agent swarms need exactly what a chat room needs: shared channels to coordinate, direct messages to stream results. An orchestrator publishes subtasks into a workflow channel; each agent streams its tool-call output back as direct messages; who-does-what rides in peer metadata.
await client.subscribe("workflow/build-42"); // every agent joins the job
await client.publish("workflow/build-42", { task: "summarize" }); // orchestrator fans out work
await client.send(orchestratorPeerId, { chunk }); // agent streams results back 1:1
IoT & device control. Each device gets a JWT scoped to its own channels (devices/<id>/**), so a compromised sensor can't touch the rest of the fleet. Devices publish telemetry and listen for commands over one socket — and your backend issues those commands with a single authenticated POST, never holding a connection at all.
// backend: server-side publish over REST — no WebSocket needed
await fetch(`https://rms.metered.ca/v1/channels/${encodeURIComponent("devices/dev-17/commands")}/publish`, {
method: "POST",
headers: { Authorization: `Bearer ${SK}`, "Content-Type": "application/json" },
body: JSON.stringify({ data: { action: "reboot" }, from: "control-plane" }),
});
The same primitives cover collaborative apps — cursors and game state, with identity cached from presence and updates throttled to ~30 Hz to stay inside the rate limits — and WebRTC signaling, which the companion guide covers end to end.
For the person deciding rather than building, the short version:
| Metered Realtime at a glance | |
|---|---|
| SDK |
@metered-ca/realtime — MIT, zero dependencies, ~13 KB gzipped |
| Free tier | 100 concurrent connections · 100,000 messages/month · no card |
| Typical latency | p50 ≈ 5 ms · p99 ≈ 20 ms (global service) |
| Uptime | Five-nines historically (a track record, contractual SLA available in the paid plans) |
| Wire protocol | Raw JSON over WebSocket — any language can speak it |
| Control plane | REST: mint tokens, publish server-side, kick peers, query usage |
| WebRTC extra | TURN credentials delivered automatically on connect |
Frequently asked questions
How many concurrent connections can one WebSocket server handle?
A single well-tuned Node process can hold tens of thousands of mostly-idle connections; the real ceiling is memory per socket and your message rate, not a hard number. Past one box you need horizontal scale (multiple servers + a shared pub/sub layer), which is where most of the operational work lives.
Is ws a good socket.io alternative?
For most apps, yes. socket.io bundles conveniences like auto-reconnect and rooms, at the cost of a heavier client and a socket.io-specific protocol on both ends; ws is a lean, standards-pure WebSocket you extend as needed. If you want the reconnection and rooms socket.io gives you without running the server at all, that's the managed path above.
Do I need wss:// (TLS)?
In production, yes. Browsers block insecure ws:// from HTTPS pages, so you terminate TLS at a reverse proxy and serve wss://.
Can I build a WebSocket server in Python / Go / Java instead of Node?
Yes — the protocol is language-agnostic. Python's websockets (shown above), Go's gorilla/websocket, and Java/Spring all follow the same accept-and-route shape. The build-vs-buy trade-off is identical regardless of language: the code is easy, the operations aren't.
What's the difference between a WebSocket server and a "WebSocket as a service"?
A WebSocket server is software you run. "WebSocket as a service" (a managed WebSocket service) is that server operated for you — connections, scaling, reconnection, and uptime become the provider's job, and you connect as a client. It's the "buy" side of this article.
What can you build on a managed WebSocket service?
Anything the DIY server would carry: live chat and presence, AI-agent coordination, IoT telemetry and device control, collaborative cursors and game state, and WebRTC signaling. The patterns above are the same three primitives — channels, presence, direct messages — pointed at different workloads.
Is a WebSocket server the same as a WebRTC signaling server?
No, but they're related: WebRTC signaling usually runs over a WebSocket server. The signaling server is a specific use of the pattern here — relaying SDP/ICE between peers. See the companion WebRTC signaling guide.
Get the complete code
The full runnable demo — server.js, rooms-server.js, server.py, index.html, the serverless metered-chat.html, and a README — is in the companion repo: github.com/jamesbordane57/websocket-server-demo.
jamesbordane57
/
websocket-server-demo
Minimal WebSocket servers in Node.js (ws) + Python: broadcast, rooms, presence, heartbeat — companion code for the WebSocket Server tutorial
Minimal WebSocket server (Node.js + ws)
Companion code for the tutorial "WebSocket Server: How to Build One in Node.js — and When to Use a Managed One."
📖 Full tutorial: <article-url-pending>
Servers, from simplest to slightly-less-simple:
-
server.js— the smallest useful server: relays every message to all other connected clients. -
rooms-server.js— adds rooms (channels), presence (peer-joined/peer-left), a small JSON protocol, and a ping/pong heartbeat that reclaims dead connections. -
server.py— the same broadcast server in Python (websocketslibrary), to show the pattern is language-agnostic.
Plus index.html — a tiny browser chat client that talks to rooms-server.js.
Run it
npm install
# broadcast server:
npm start
# …or the rooms + presence + heartbeat server:
npm run rooms
Or the same broadcast server in Python:
pip install websockets
python server.py
Then open index.html in two browser tabs (from file:// is fine, or npm run…
git clone https://github.com/jamesbordane57/websocket-server-demo.git
cd websocket-server-demo
npm install
npm start # broadcast server
npm run rooms # rooms + presence + heartbeat server
Wrapping up
Building a WebSocket server in Node.js is genuinely easy — fifteen lines for broadcast, fifty for rooms, presence, and a heartbeat, and you saw both run (plus the same thing in Python). What's not easy is everything that keeps one alive under real traffic: reconnection, auth, backpressure, multi-box scale, and five-nines reliability. Then you watched the same app run again with the server deleted.
So make the call deliberately. If you need infrastructure control or on-prem, run your own — you now have the starting point.
If you'd rather ship your product than operate socket servers, connect to a managed one and move on. You can start free with Metered Realtime Messaging and skip the 95%.






Top comments (1)
Thank you for reading I hope you like the article