There are three separate decisions hiding inside "should we use WebSockets". First, the wire protocol: raw RFC 6455 frames, Socket.IO's own framing on top of Engine.IO, Server-Sent Events over plain HTTP, or WebTransport over HTTP/3. Second, the server library: ws for ordinary Node apps, µWebSockets.js when you need tens of thousands of sockets per process, or whatever your framework ships. Third, whether you run it yourself at all, or hand the connection layer to Ably, Pusher, Supabase Realtime or a Cloudflare Durable Object.
| Option | Direction | Reconnect | Main cost |
|---|---|---|---|
Browser WebSocket | Both ways | You write it | No rooms, no acks, no buffering |
| Socket.IO | Both ways | Built in, with backoff | Custom protocol, client and server must match |
ws (Node server) | Both ways | Client's problem | Manual heartbeats, manual fan-out |
| µWebSockets.js | Both ways | Client's problem | Native addon, different API from ws |
SSE (EventSource) | Server to client | Free, with Last-Event-ID | One direction; per-host connection limits on HTTP/1.1 |
| SockJS | Both ways | Built in | Fallback transports you probably no longer need |
| WebTransport | Both ways, multi-stream | You write it | HTTP/3 only, uneven browser and proxy support |
| Hosted (Ably, Pusher, Durable Objects, Supabase) | Both ways | Handled for you | Per-message or per-connection billing, vendor SDK |
SSE is a normal HTTP response with Content-Type: text/event-stream that never ends. That means every proxy, WAF, corporate firewall and CDN in the path already knows what to do with it, which is not always true of an Upgrade handshake. Reconnection is in the browser: emit an id: field with each event and the client sends Last-Event-ID on reconnect so you can replay the gap. You get that for zero lines of client code.
The historical objection was the six-connections-per-host limit on HTTP/1.1; over HTTP/2 that is effectively gone. Use SSE for notifications, job progress, log tails and token streaming. Use WebSockets when the client genuinely sends a high rate of messages, as in collaborative editing, multiplayer input or a trading UI.
Socket.IO is not a WebSocket client. It speaks its own protocol, so a Socket.IO browser client cannot talk to a bare ws server and vice versa, and major-version upgrades have broken client/server compatibility before. In exchange you get reconnection with exponential backoff, acknowledgement callbacks, rooms and namespaces, and binary support. If you need a public API that third parties consume, ship raw WebSockets. If you are shipping an internal dashboard, the rooms API alone will save you a week.
A WebSocket is stateful, so the second you run two instances behind a load balancer, a message published on box A has no way to reach a subscriber pinned to box B. You need two things. Sticky sessions, so the HTTP Upgrade and any long-poll fallback land on the same node, usually via IP hash or a cookie on the balancer. And a pub/sub adapter, typically Redis, so a publish fans out to every node holding relevant sockets. Socket.IO ships an official Redis adapter; with plain ws you write the twenty lines yourself.
Per-connection memory in Node is a few kilobytes of library state plus whatever you attach to each socket, so an idle server holds tens of thousands of connections in well under a gigabyte. What kills you first is your own per-user objects and buffered outbound data, not the socket itself.
Most load balancers and reverse proxies close idle connections somewhere in the 30 to 60 second range, and many mobile carrier NATs are more aggressive still. Send a ping every 25 to 30 seconds. On the server, mark each socket alive on pong and terminate anything that misses two intervals, otherwise you accumulate half-open sockets that look connected and are not. Use wss:// everywhere; plaintext ws:// is blocked from HTTPS pages and mangled by intermediaries.
Backpressure is the failure mode nobody plans for. If you push faster than a slow mobile client can drain, the kernel and library buffers grow without bound and one bad client drives your process out of memory. Check socket.bufferedAmount in the browser and the equivalent on the server, and when it crosses a threshold, drop stale updates or disconnect rather than queue. For state-style feeds, sending only the latest snapshot is almost always better than replaying a backlog nobody will read.
Free tools, guides, and resources across the SPUNK13 network.
Visit spunk.bet400+ Free Tools