Concept
The beginner framing: regular HTTP is request-then-response, the client always initiates, the server always replies, then the connection (conceptually) is done. Some features (live chat, real-time notifications, collaborative editing) need the server to push data to the client at ANY time, not just in reply to a request, WebSockets and SSE are the two standard ways to do that.
WebSockets, a full-duplex, persistent connection
// Confirmed by running a real ws server + client, this session:
const { WebSocketServer } = require("ws");
const wss = new WebSocketServer({ port: 8181 });
wss.on("connection", (ws) => {
ws.on("message", (data, isBinary) => {
console.log(typeof data, Buffer.isBuffer(data)); // 'object' true, a BUFFER, not a string!
ws.send(`echo: ${data}`);
});
});A WebSocket starts as a regular HTTP request (an Upgrade: websocket handshake) and then the SAME underlying TCP connection is repurposed into a persistent, full-duplex channel, both client and server can send messages at any time, independently, without waiting for a "response" to anything. Confirmed directly by running a real ws server and client this session: incoming message data arrives in the message event as a Buffer, not a JavaScript string, even for plain text messages, data.toString() is required to get a usable string, a detail that surprises people the first time (console.log(data) prints something that LOOKS like it could be a string but typeof data === "object").
Server-Sent Events (SSE), simpler, one-directional, still just HTTP
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
id: 1
data: {"tick":1}
id: 2
data: {"tick":2}Confirmed with a real running server this session (captured via curl -N, showing the exact raw wire format above): SSE is dramatically simpler than WebSockets, it's just a regular HTTP response that never closes, with Content-Type: text/event-stream, where each event is plain text (id:, data:, optionally event: for a named event type) separated by a blank line. The browser's native EventSource API handles parsing this format and auto-reconnecting on disconnect, genuinely built into the platform, no library needed client-side. The fundamental limitation: it's one-directional only, the server can push to the client, but the client has no channel back over the same connection; any client-to-server communication needs a separate, regular HTTP request.
Choosing between them
Need server→client push only? → SSE (simpler, auto-reconnect built in, plain HTTP)
Need true bidirectional, low-latency? → WebSockets (chat, collaborative editing, gaming)
Need it to work through more proxies/
older infra with less friction? → SSE (it's just HTTP, no protocol upgrade)SSE's simplicity is a real, substantial advantage when the actual requirement is one-directional, it's plain HTTP, so it passes through existing infrastructure (proxies, load balancers, corporate firewalls) far more transparently than a protocol-level Upgrade, and the browser's native reconnection logic is genuinely one less thing to build and get wrong. WebSockets earn their added complexity specifically when the client genuinely needs to push data back over the SAME low-latency channel, SSE would require a second, separate request path for that direction, at which point WebSockets' unified channel usually wins.
Try It
Predict the outcome before checking the solution.
wss.on("connection", (ws) => {
ws.on("message", (data) => {
if (data === "ping") { // comparing a Buffer to a string with ===
ws.send("pong");
}
});
});
// Client sends: ws.send("ping");Does the server ever respond with "pong"?
Solution
No, this is exactly the Buffer-vs-string trap confirmed in the Concept section. data arrives as a Buffer object, not a string; Buffer("ping") === "ping" is false (comparing a Buffer object reference to a string primitive never matches via ===, regardless of its contents). The condition never passes, and the server silently never responds, no error is thrown, making this a particularly easy bug to miss in testing if you're not specifically checking for the response. The fix is data.toString() === "ping", or comparing against a Buffer.from("ping") if binary comparison is genuinely intended.
Implement It Yourself
Build a minimal WebSocket broadcast mechanism, the actual mechanism behind "everyone in this chat room sees this message":
const { WebSocketServer } = require("ws");
const wss = new WebSocketServer({ port: 8181 });
const clients = new Set();
wss.on("connection", (ws) => {
clients.add(ws);
ws.on("message", (data) => {
const message = data.toString(); // ALWAYS convert, confirmed this is a Buffer
// broadcast to every OTHER connected client:
for (const client of clients) {
The mechanism: the server maintains a Set of every currently-open connection, and on each incoming message, iterates and forwards it to every OTHER client, this is the entire core of a chat room or live-collaboration broadcast feature. The readyState === client.OPEN check matters because a client's connection can be in the process of closing (or already closed) without the close handler having fired yet, sending to a non-open socket throws. Removing from the clients Set on close is not optional: skipping it means the Set grows forever as clients connect and disconnect, holding references to dead sockets, a genuine, easy-to-introduce memory leak in any long-running WebSocket server.
Under the Hood
The single-threaded event-loop model covered in Event Loop is exactly what makes a Set-based broadcast loop like the one above safe without explicit locking, since Node processes one callback at a time, there's no risk of two message handlers concurrently mutating the clients Set and corrupting it, unlike in a genuinely multi-threaded server. And Polling & Long-polling Patterns covers the alternative approach to "real-time-ish" updates that predates and still sometimes substitutes for both WebSockets and SSE, with its own honest tradeoffs.
Common Mistakes
1. Comparing incoming WebSocket message data directly to a string
if (data === "ping") { ... } // ❌ data is a Buffer, this NEVER matches, confirmed this sessionAs demonstrated in Try It, always call .toString() (or JSON.parse(data.toString()) for JSON payloads) before comparing or using message data as a string.
2. Forgetting to remove closed connections from a broadcast set
wss.on("connection", (ws) => {
clients.add(ws);
// ❌ no ws.on("close", () => clients.delete(ws)), clients Set grows forever
});Every connection that closes without being removed from tracking structures leaks memory indefinitely in a long-running server, this compounds over the server's uptime and is exactly the kind of bug that's invisible in short-lived local testing but real in production.
3. Using WebSockets when SSE (or even polling) would fully cover the actual requirement
// A live "new notification count" badge, server→client only, implemented with a full WebSocket setupIf the actual requirement is purely server-to-client, reaching for WebSockets by default adds real complexity (connection lifecycle management, reconnection logic that SSE gets for free from the browser, Buffer handling) that isn't buying anything, SSE (or even simple polling for a low-frequency case) is a better-fit, simpler solution.
Best Practices
- Always call
.toString()on incoming WebSocket message data before comparing or parsing it, it arrives as a Buffer, confirmed directly against a real running server. - Always remove closed connections from any tracking structure (
Set,Map) in theclosehandler, unbounded growth is a real, common memory leak in long-running WebSocket servers. - Check
readyState === OPENbefore sending to a tracked connection, a socket can be mid-close before itscloseevent has fired. - Default to SSE for one-directional server-push requirements, it's simpler, gets auto-reconnection for free from the browser's native
EventSource, and passes through existing HTTP infrastructure more transparently than a protocol upgrade. - over the same connection, not merely because "real-time" sounds like it needs the more powerful tool.
Performance Tips
- A persistent WebSocket connection avoids the overhead of establishing a new HTTP connection/TLS handshake per message (unlike polling), for genuinely high-frequency bidirectional traffic, this is a real, measurable win.
- Every open WebSocket connection consumes server memory and a file descriptor for its entire lifetime, a server design needs to account for this per-connection cost at the expected concurrent-connection scale, unlike stateless request/response HTTP handling where a connection's resources are held only for the duration of one request.
