Real-Time Web Apps with WebSockets and Server-Sent Events
WebSockets give you full-duplex channels; SSE pushes server-to-client events over plain HTTP with auto-reconnect. Learn when to use each and how to build both.
Published on • August 10, 2026
AI Assistant

Every app eventually needs the server to talk first: a chat message, a price tick, a job progress update. The browser’s answer to “when does new data arrive?” used to be polling — a request every N seconds, most of them empty. The modern answers are WebSockets and Server-Sent Events (SSE), and they solve different problems.
In this post, you will learn the difference between WebSockets and SSE, when to choose each, and how to build both — a browser client, a Node.js server, and the patterns for reconnection, backpressure, and scaling.
WebSockets vs. SSE: the decision table
| WebSockets | Server-Sent Events | |
|---|---|---|
| Direction | Full-duplex (both ways) | Server → client only |
| Transport | Upgraded TCP connection | Plain HTTP (long-lived response) |
| Protocol | ws:// / wss:// | HTTP + text/event-stream |
| Browser API | WebSocket | EventSource |
| Auto-reconnect | No (you implement it) | Yes, built-in |
| Binary | Yes | No (text only) |
| Proxies/firewalls | Can be problematic | Traverses HTTP infra easily |
| Payload framing | Message-oriented | data: / event: / id: frames |
The short version: if you need the client to push messages to the server (chat, multiplayer, live cursors), use WebSockets. If you need a one-way feed (notifications, progress, live prices, LLM token streams), use SSE. SSE rides existing HTTP infrastructure, reconnects automatically, and is dramatically simpler to secure.
SSE: the server pushes over plain HTTP
SSE is a response that never ends. The server writes frames in a specific text format and the browser’s EventSource does the rest — including automatic reconnection using the Last-Event-ID header.
Node.js server:
import http from "node:http";
http.createServer((req, res) => {
if (req.url === "/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
let n = 0;
const timer = setInterval(() => {
// The SSE wire format: optional event, id, then data
res.write(`id: ${n}\n`);
res.write(`event: tick\n`);
res.write(`data: ${JSON.stringify({ count: n++ })}\n\n`);
}, 1000);
req.on("close", () => clearInterval(timer));
return;
}
res.end("ok");
}).listen(3000);
Browser client:
const events = new EventSource("/events");
// Named event from the `event:` field
events.addEventListener("tick", (e) => {
console.log("tick", JSON.parse(e.data));
});
// The default `message` event for unnamed frames
events.onmessage = (e) => console.log("message", e.data);
// Reconnection is automatic — EventSource reconnects using Last-Event-ID
events.onerror = () => console.error("connection lost — retrying automatically");
Send an id: with every frame and EventSource will send it back as Last-Event-ID on reconnect, letting your server resume from where the client last heard — that’s the at-least-once delivery story SSE gives you nearly for free.
WebSockets: full-duplex, both directions
When the client must also send messages over the same live connection — chat, collaborative editing, real-time gaming — WebSockets are the tool.
Node.js server (using the ws library):
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
const clients = new Set();
wss.on("connection", (socket) => {
clients.add(socket);
socket.on("message", (raw) => {
const msg = JSON.parse(raw);
console.log("received", msg);
// Broadcast to everyone except the sender
for (const client of clients) {
if (client !== socket && client.readyState === socket.OPEN) {
client.send(JSON.stringify({ ...msg, from: socket.id }));
}
}
});
socket.on("close", () => clients.delete(socket));
});
Browser client:
const socket = new WebSocket("wss://example.com/ws");
socket.addEventListener("open", () => {
socket.send(JSON.stringify({ type: "join", room: "general" }));
});
socket.addEventListener("message", (e) => {
console.log(JSON.parse(e.data));
});
// WebSockets do NOT auto-reconnect — you own the retry loop
function connect() {
const ws = new WebSocket("wss://example.com/ws");
ws.addEventListener("open", () => scheduleHeartbeat(ws));
ws.addEventListener("close", () => setTimeout(connect, 3000));
ws.addEventListener("error", () => ws.close());
}
Heartbeats: keep dead connections honest
Both protocols need liveness detection, but for different reasons. SSE’s EventSource reconnects on its own; WebSockets you must babysit. Send a ping from the server at a fixed interval and treat a missed pong (or a silent window) as a dead socket:
// Server-side heartbeat
const heartbeat = setInterval(() => {
wss.clients.forEach((socket) => {
if (socket.isAlive === false) return socket.terminate();
socket.isAlive = false;
socket.ping();
});
}, 30_000);
socket.on("pong", () => { socket.isAlive = true; });
Scaling beyond one process
The classic scaling trap: a message sent to server instance A never reaches clients connected to instance B. The fix is a pub/sub backbone — Redis Pub/Sub, a message queue, or any broker — where every instance subscribes and publishes.
import { createClient } from "redis";
const redis = createClient();
await redis.connect();
// Subscribe to the "chat" channel and forward to local clients
await redis.subscribe("chat", (message) => {
wss.clients.forEach((client) => client.send(message));
});
// On receive, publish to the channel instead of broadcasting locally
socket.on("message", (raw) => redis.publish("chat", String(raw)));
Now instance A publishes, Redis fans it out, and instance B’s subscription pushes it to its local sockets. Same pattern applies to SSE: a worker consumes a queue and writes frames to its connected EventSource clients.
Putting It All Together
A runnable demo with both transports — an SSE feed with id:/event: frames and automatic resume, plus a WebSocket chat with heartbeat and Redis pub/sub so it survives multi-instance deployment. Spin up two server processes, connect from two tabs, and watch messages cross instances.
Conclusion & Next Steps
You can now pick the right real-time primitive: SSE for one-way pushes over reliable HTTP, WebSockets for two-way conversations. You’ve built both, added heartbeats, and handled cross-instance scaling with pub/sub. Next: add authentication to both (an Authorization cookie for EventSource, a token handshake for WebSocket), add backpressure handling for slow consumers, and consider SSE for your LLM streaming responses — it’s a natural fit.
References / Sources
- MDN: Server-sent events — concepts, usage, and the EventSource interface. https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
- MDN: Using server-sent events guide. https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
- MDN: The WebSocket API. https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
- The
wsWebSocket library for Node.js. https://github.com/websockets/ws