Skip to content
Blog

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

WebSocketsServer-Sent Events
DirectionFull-duplex (both ways)Server → client only
TransportUpgraded TCP connectionPlain HTTP (long-lived response)
Protocolws:// / wss://HTTP + text/event-stream
Browser APIWebSocketEventSource
Auto-reconnectNo (you implement it)Yes, built-in
BinaryYesNo (text only)
Proxies/firewallsCan be problematicTraverses HTTP infra easily
Payload framingMessage-orienteddata: / 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