WebSockets and server-sent events

HTTP & the web · Advanced · 14 min

Chats, notifications, live dashboards and collaborative editing all need the server to talk first.

Plain HTTP is client-initiated: the server can only respond. For real-time updates there are three main options.

Polling: ask every few seconds. Simple and cache-friendly, but wasteful and laggy. Long polling (the server holds the request until there's news) reduces lag.

Server-sent events (SSE): the client makes one ordinary GET; the server replies Content-Type: text/event-stream and keeps the response open, writing events as lines of text:

event: price
data: {"symbol":"ACME","value":12.5}

followed by a blank line. Browsers use new EventSource(url), which reconnects automatically and resends the last event id in Last-Event-ID. It's one-way (server to client), text-only, and works through normal HTTP infrastructure. Over HTTP/1.1 each stream ties up one of the ~6 connections per origin; HTTP/2 removes that limit. LLM APIs stream tokens this way.

WebSockets: a full-duplex, message-based channel. The client sends a GET with Upgrade: websocket and a Sec-WebSocket-Key; the server answers 101 Switching Protocols, and from then on both sides send frames in either direction, text or binary, with very low overhead. Use wss:// (TLS). You handle reconnection, heartbeats and message formats yourself, and proxies and load balancers must support upgrades and long-lived connections.

Rule of thumb: SSE for server-to-client feeds; WebSockets when the client also sends frequently (chat, games, collaborative editing). The newer WebTransport over HTTP/3 targets similar use cases with multiple streams and unreliable datagrams.

Example

import http from "node:http";

// SSE server: one long-lived response, events as text lines
const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
  let n = 0;
  const timer = setInterval(() => {
    n++;
    res.write(`id: ${n}\nevent: tick\ndata: ${JSON.stringify({ n })}\n\n`);
    if (n === 3) { clearInterval(timer); res.end(); }
  }, 100);
});

server.listen(0, async () => {
  const res = await fetch(`http://localhost:${server.address().port}/events`);
  console.log(await res.text());
  // id: 1 / event: tick / data: {"n":1}   ... three events, blank-line separated
  server.close();
});

// Browser side:
// const es = new EventSource("/events");
// es.addEventListener("tick", (e) => console.log(JSON.parse(e.data)));
// const ws = new WebSocket("wss://chat.example.com"); ws.onmessage = (m) => ...

Remember

  • SSE = one long GET with text/event-stream; auto-reconnecting, server to client only
  • WebSockets upgrade via 101 Switching Protocols to a two-way channel
  • Use SSE for feeds, WebSockets for interactive two-way traffic
  • Proxies and load balancers must allow long-lived connections

Watch out for

  • Using WebSockets for a one-way notification feed and then re-implementing reconnection that SSE gives for free.

Check yourself

  1. Which status code does a server send to accept a WebSocket handshake?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events

Builds on

Unlocks

Nothing builds on this yet.