Node.js의 WebSocket: 실시간 앱 바로 실행

작성자

카테고리:

← 피드로
DEV Community · Gulshan Yadav · 2026-09-06 개발(SW)

From a raw ws echo server to a horizontally scaled Socket.IO deployment — with the heartbeat, backpressure, and reconnection logic that keeps real-time apps alive in production.

A client came to me with a dashboard that fell over. It was a fleet-tracking product — trucks reporting positions, dispatchers watching them on a live map — and the backend was polling a REST endpoint every three seconds from every open browser tab. At 400 concurrent dashboards, the polling storm was consuming the whole API budget. At 800, requests started timing out in waves.

The fix was not more servers. It was WebSockets: one long-lived connection per client, the server pushing updates the moment they arrive, instead of clients hammering the API asking “anything new?” This article is the playbook I used — from the bare ws server to a scaled, production-grade deployment, including every mistake I made so you can skip them.

Why WebSockets, and When They Are the Wrong Answer

A WebSocket is a single TCP connection, upgraded from a regular HTTP request via the 101 Switching Protocols handshake, that stays open and carries messages in both directions with low overhead. Where polling pays the cost of a full HTTP round trip for every “anything new?”, a WebSocket pays it once and then streams.

The trade-off is real and you should hear it before you build: WebSockets add connection state. Every open socket is memory on your server, a connection on your load balancer, and a monitoring surface. If your feature needs an update every 30 seconds or less often, polling is cheaper and simpler. If you need sub-second updates, or the server must push without the client asking — live chat, presence, a ticking price ticker, collaboration cursors, truck positions — WebSockets are the right tool. Use them where latency is the product.

Step 1: The Minimal ws Server

Start with nothing but the ws package. This is the complete skeleton:

npm install ws

Enter fullscreen mode Exit fullscreen mode

// server.js
import { WebSocketServer } from "ws";
import http from "http";

const server = http.createServer((req, res) => {
  res.writeHead(200);
  res.end("WebSocket server");
});

const wss = new WebSocketServer({ server, path: "/live" });

wss.on("connection", (ws, req) => {
  console.log("connected:", req.socket.remoteAddress);

  ws.on("message", (data) => {
    // Broadcast to everyone else
    wss.clients.forEach((client) => {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(data.toString());
      }
    });
  });

  ws.on("close", () => console.log("disconnected"));
});

server.listen(8080, () => console.log("listening on :8080"));

Enter fullscreen mode Exit fullscreen mode

The client side is equally small:

const ws = new WebSocket("wss://api.example.com/live");
ws.onmessage = (e) => updateDashboard(JSON.parse(e.data));
ws.onclose = () => scheduleReconnect();

Enter fullscreen mode Exit fullscreen mode

That is the whole idea. Everything else in this article is about keeping it alive and correct at scale.

Step 2: ws vs Socket.IO — the Honest Decision

I use both, and I choose based on one question: how much of the transport machinery do I want to write myself?

Raw ws gives you a lightweight connection and total control. You pay for that control with hand-written reconnection, rooms, and broadcasting logic. Great for a single purpose, a few endpoints, or when you need maximum throughput and minimal overhead.

Socket.IO gives you rooms, namespaces, automatic reconnection with exponential backoff, acknowledgements, and a Redis adapter for horizontal scaling — out of the box. It costs you a small protocol overhead and a layer of abstraction. When the feature is chat, presence, or any multi-client real-time product, I reach for Socket.IO and stop re-inventing wheels.

My rule: ws for one-to-many streaming to one audience; Socket.IO for anything with rooms, presence, or reconnection requirements. Both sit on the same protocol and both scale fine if you do the next steps.

Step 3: Heartbeats — the Connection That Would Not Die

The classic production failure: clients vanish without sending a close frame. A laptop goes to sleep, a phone crosses a cell boundary, a browser tab is backgrounded — the TCP connection stays half-open in your server’s memory forever. Without a heartbeat, your wss.clients list slowly fills with ghosts, and your process runs out of sockets at exactly the worst moment.

The fix is a ping/pong cycle. The server pings; the client must pong; a client that does not respond is dead and gets closed:

const HEARTBEAT_MS = 30_000;

wss.on("connection", (ws) => {
  ws.isAlive = true;
  ws.on("pong", () => { ws.isAlive = true; });
});

const heartbeat = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) return ws.terminate();
    ws.isAlive = false;
    ws.ping();
  });
}, HEARTBEAT_MS);

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

Enter fullscreen mode Exit fullscreen mode

With that one interval, the server reaps dead connections within two heartbeat cycles. The fleet dashboard went from dying at 800 sockets to holding 5,000+ comfortably, because nothing was a ghost anymore. If you take one thing from this article, it is the heartbeat.

Step 4: Backpressure — When the Server Cannot Keep Up

The second silent killer is backpressure. If you are pushing 10 messages per second to a client on a slow connection, the socket buffer fills, memory climbs, and the process eventually explodes — not with a clean error, with an OOM crash.

With raw ws, check the buffered amount before you send, and slow down or drop when it is too high:

const MAX_BUFFER = 1_000_000; // 1 MB

function safeSend(ws, message) {
  if (ws.bufferedAmount > MAX_BUFFER) {
    ws.close(1009, "client too slow"); // 1009 = message too big
    return;
  }
  ws.send(message);
}

Enter fullscreen mode Exit fullscreen mode

Socket.IO exposes the same reality through its drain and backpressure events; the principle is identical — never let an unbounded send queue accumulate in process memory. A real-time system that respects backpressure crashes with the predictable “client dropped” path instead of the mysterious OOM that takes the whole box down.

Step 5: Authentication at the Handshake, Not After

WebSockets do not automatically carry your HTTP cookies, and putting a token in the URL query string leaks it into every access log and proxy log on the path. Authenticate at the handshake, and pass the token in a way that survives. The ws server lets you inspect the upgrade request:

import { createServer } from "http";
import { WebSocketServer } from "ws";

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

server.on("upgrade", (req, socket, head) => {
  const token = extractTokenFromCookie(req.headers.cookie);
  if (!isValidToken(token)) {
    socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
    socket.destroy();
    return;
  }
  wss.handleUpgrade(req, socket, head, (ws) => {
    wss.emit("connection", ws, req);
  });
});

Enter fullscreen mode Exit fullscreen mode

Validate once at the upgrade — do not accept connections from anonymous clients and hope the first message is authenticated. A server that accepts every socket is free bait for every scraper and crawler on the internet.

Step 6: Scaling — Multiple Instances, One Logical Room

The day you hit one process’s limits, you need multiple instances — and now every client’s socket lives on a different box. A message broadcast in one process must reach sockets on the others. That is what Redis pub/sub is for, and Socket.IO ships an adapter for it:

npm install @socket.io/redis-adapter ioredis

Enter fullscreen mode Exit fullscreen mode

import { createServer } from "http";
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "ioredis";

const httpServer = createServer();
const io = new Server(httpServer, { cors: { origin: "https://app.example.com" } });

const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
io.adapter(createAdapter(pubClient, subClient));

io.on("connection", (socket) => {
  socket.on("join:fleet", (fleetId) => socket.join(`fleet:${fleetId}`));
  socket.on("position:update", (data) => {
    socket.to(`fleet:${data.fleetId}`).emit("position", data);
  });
});

Enter fullscreen mode Exit fullscreen mode

Every instance subscribes to the same channels; a message emitted on one instance is published to Redis and delivered to sockets on every other instance. Rooms work across the whole cluster as if it were one server.

Two scaling rules that will save your weekend:

  1. Sticky sessions are required with plain ws on a multi-instance setup — the socket must land on the same instance for every message. Configure your load balancer for sticky connections by IP or cookie.
  2. Socket.IO with the Redis adapter handles cross-instance delivery, but the load balancer still needs sticky routing for the connection itself, or reconnects land on a new instance and lose their rooms until they rejoin.

When to skip the cluster entirely

Before you build horizontal scaling, run the numbers. A single Node process holds tens of thousands of idle WebSocket connections comfortably — the fleet dashboard ran 5,000 connections on one box after the heartbeat fix, and the CPU barely twitched, because idle sockets cost memory, not CPU. You only need multiple instances when the message rate — the actual broadcasts per second — outgrows one process, or when you need zero-downtime deploys that do not drop every connection at once.

That last one is the reason I have seen most teams move to a cluster: a deploy that kills 5,000 connections in one moment is a visible outage. With two instances and a drain policy — a signal to the load balancer, finish in-flight work, close sockets gracefully, then redeploy the other — you ship without the map going blank. If you do not need that, one well-sized instance with the Redis adapter is simpler and cheaper.

Step 7: Reconnection — Assume the Connection Dies

Networks drop WebSockets. The mobile fleet app went through tunnels, elevators, and airport waiting lounges, and reconnection is what made it usable. Socket.IO does this for you with exponential backoff out of the box. With raw ws, write it yourself:

function connect() {
  const ws = new WebSocket("wss://api.example.com/live");
  ws.onopen = () => { /* resume subscriptions */ };
  ws.onclose = (e) => {
    if (e.code === 1000) return; // clean close, no retry
    setTimeout(connect, Math.min(1000 * 2 ** attempts++, 30_000));
  };
}

Enter fullscreen mode Exit fullscreen mode

Cap the backoff, reset it after a successful open, and resubscribe to the rooms you were in before the drop. Clients that reconnect quickly are the difference between “the map went blank in the tunnel” and “the map caught up when we exited”.

Step 8: Validate Messages, Even Though It Is a Socket

A WebSocket is still a network boundary. The message your server receives is untrusted data — a client can send anything, and a malicious one can send messages that look like internal events. In the fleet system, the first incident was a client broadcasting a crafted “position” event with a spoofed fleet ID, polluting every dispatcher’s map. The fix was validating every inbound message before it did anything:

import { z } from "zod";

const PositionEvent = z.object({
  type: z.literal("position:update"),
  fleetId: z.string().min(1).max(64),
  lat: z.number().min(-90).max(90),
  lng: z.number().min(-180).max(180),
  vehicleId: z.string().min(1).max(32),
});

socket.on("message", (raw) => {
  const parsed = PositionEvent.safeParse(JSON.parse(raw.toString()));
  if (!parsed.success) {
    socket.emit("error", { message: "invalid event" });
    return;
  }
  // Only now touch rooms and broadcast.
});

Enter fullscreen mode Exit fullscreen mode

Treat inbound socket messages exactly like HTTP request bodies: schema-validate, then act. And never let a client choose arbitrary rooms to join — rooms are an authorization boundary. join:fleet should check “is this user allowed in this fleet?” before it joins, the same way an HTTP route checks ownership.

Step 9: Observability — You Cannot Fix What You Cannot See

A real-time system hides its failures. A polling API fails loudly (the request times out); a WebSocket system fails quietly (the client silently stops getting updates and nobody notices for an hour). So a real-time deployment needs counters, and it needs them from day one:

  • Connection count per instance — your first signal that something is about to break.
  • Message rate in and out — a sudden spike is either a feature going viral or a client in an infinite loop.
  • Reconnect rate — climbing reconnects mean flapping connections, usually a load-balancer or heartbeat problem.
  • Per-room membership — a room that grows without bound is a leak.
  • Heartbeat misses — if your reaping interval fires more than usual, something is dropping pings.

Every one of those metrics is a few lines of incrementing a counter. In the fleet system, connection count per instance is the number I watch first: when one box’s count climbs past the others, sticky sessions are misrouting, and that is a load-balancer problem, not an application one.

Pitfalls I Have Collected (So You Skip Them)

  1. No heartbeat. Dead sockets accumulate silently until the process OOMs. This was the fleet dashboard’s actual root cause.
  2. Unbounded sends. Ignoring bufferedAmount and the process dies from memory pressure instead of a clean drop.
  3. Tokens in the URL. Every proxy and access log now has your session token. Use cookies or a header at the upgrade.
  4. No auth at handshake. Accepting anonymous sockets makes your server a free relay for any bot.
  5. Forgetting sticky sessions. Connections hop instances, rooms break, and you debug “works on my machine, dies in production” for a day.
  6. No connection cap. A single IP can open thousands of sockets. Rate-limit connections per IP and per account, or a three-line script takes your server down.

The Production Checklist

  • [ ] WebSocket use is justified (sub-second push required) — otherwise prefer polling
  • [ ] Heartbeat ping/pong with dead-connection reaping
  • [ ] Backpressure handled — bufferedAmount checked, slow clients dropped cleanly
  • [ ] Authentication at the handshake, token not in the URL
  • [ ] Inbound messages schema-validated; room joins authorization-checked
  • [ ] Message size limits and per-IP/per-account connection caps
  • [ ] Sticky sessions configured at the load balancer
  • [ ] Redis adapter for cross-instance rooms/broadcast
  • [ ] Reconnection with capped exponential backoff + subscription resume
  • [ ] Monitoring: connection count, message rate, reconnects, and OOM indicators per instance

The fleet dashboard now streams truck positions to dispatchers in real time on a handful of connections instead of a polling storm. It did not need exotic infrastructure — it needed one long-lived connection, a heartbeat to know which connections were alive, backpressure to stay honest, and the discipline to authenticate and scale it properly. That is what “real-time done right” looks like: boring, measured, and alive.

*Gulshan Yad

원문에서 계속 ↗