Updated Jun 30, 2026

The Three Patterns in Practice

You've got the mental model: HTTP can't push, and there are three ways around it. Now let's make them real. We'll go up the ladder — polling, then SSE, then WebSockets — and for each one you'll see the actual code on both ends and exactly what travels over the wire. By the end you'll be able to copy the right pattern, not only name it.

A small grounding note before we start: all three of these run over the same TCP connections your browser already uses. Nothing magic, no new network. They're different conventions for keeping a conversation going past the usual one-request-one-reply.

Polling and its smarter cousin, long-polling

Plain polling — ask on a timer.

// Client: ask every 5 seconds, forever.
setInterval(async () => {
  const res = await fetch("/api/messages?since=" + lastId);
  const msgs = await res.json();
  if (msgs.length) render(msgs);
}, 5000);

What just happened: Every 5 seconds you fire a request asking "anything after lastId?" Most of the time the answer is an empty array — wasted round-trips — and a new message can sit unseen for up to 5 seconds. Cheap to build, but it's a tax you pay forever.

Long-polling — let the server hold the line. The trick: the server doesn't answer immediately. It holds your request open until it actually has something, or until a timeout, then replies. The client gets the answer the moment it exists, then immediately reconnects.

// Client: reconnect the instant the server answers.
async function longPoll() {
  while (true) {
    const res = await fetch("/api/messages?since=" + lastId);
    const msgs = await res.json();   // resolves only when there's news (or a timeout)
    if (msgs.length) { render(msgs); lastId = msgs.at(-1).id; }
  }
}
longPoll();

What just happened: The await parks here until the server sends something back, instead of you hammering on a timer. You get near-instant delivery with no persistent connection — but you're still opening a fresh HTTP request per message, and a held-open request ties up a server slot. Long-polling is the honest fallback when SSE and WebSockets aren't available; otherwise, climb the ladder.

📝 Terminology. "Long-polling" sounds like a kind of polling, but it behaves like a push: the latency is "as soon as there's news," not "next time the timer fires." The cost is connection churn — a new request after every single delivery.

Server-Sent Events: one stream, server to client

This is the one most people should be reaching for and don't. SSE keeps a single HTTP response open and dribbles events down it. The browser's EventSource handles the connection — including reconnecting if it drops — so you write almost nothing.

The client — the whole thing.

const stream = new EventSource("/api/stream");

stream.onmessage = (event) => {
  const data = JSON.parse(event.data);
  render(data);
};

stream.onerror = () => {
  // EventSource is ALREADY retrying. You don't reconnect by hand.
  console.log("connection dropped — browser is reconnecting...");
};

What just happened: Three lines and you have a live feed. onmessage fires every time the server sends an event. When the line drops, EventSource reconnects on its own and even tells the server where you left off — that auto-reconnect is the single biggest reason to prefer SSE over a hand-rolled WebSocket for one-way data.

The server — what it actually sends. SSE isn't JSON-over-HTTP; it's a tiny text format. The content type is text/event-stream, and each event is data: lines ending in a blank line.

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

data: {"price": 142.10}

data: {"price": 142.35}

id: 48
data: {"price": 142.80}

What just happened: One response that never ends. Each data: block is one event the browser hands to onmessage. The id: line is the magic behind reconnect — the browser remembers the last id and sends it back as a Last-Event-ID header when it reconnects, so the server can resume from where you dropped instead of replaying everything.

The server side, in code.

// Express-style handler.
app.get("/api/stream", (req, res) => {
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    "Connection": "keep-alive",
  });

  const tick = setInterval(() => {
    res.write(`data: ${JSON.stringify({ price: nextPrice() })}\n\n`);
  }, 1000);

  req.on("close", () => clearInterval(tick));  // stop when the client leaves
});

What just happened: You set the streaming headers, then res.write() an event whenever you have news — note the \n\n that ends each event. Crucially, you clean up on close, or you'll leak a timer (and eventually a connection) for every client who ever wandered off. That cleanup is the most-forgotten line in SSE code.

⚠️ The six-connection cap (HTTP/1.1). Over HTTP/1.1 a browser allows only ~6 connections per domain, and an open SSE stream eats one of them per tab. Open the app in a few tabs and you can starve your own page of connections. The fix is HTTP/2, which multiplexes many streams over one connection — so serve SSE over HTTP/2 in production and the cap effectively disappears.

WebSockets: a two-way pipe

When both sides need to talk, you want a WebSocket. It starts life as a normal HTTP request that asks to upgrade the connection, and once that handshake succeeds, the same TCP connection becomes a two-way channel where either side sends whenever it likes.

The handshake — how a WebSocket is born.

GET /chat HTTP/1.1
Host: yourapp.example
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade

What just happened: The client asked HTTP to "upgrade" to the WebSocket protocol; the server agreed with a 101 Switching Protocols. From this point the connection is no longer request/response — it's an open, two-way pipe. That 101 is the moment a WebSocket exists.

The client.

const ws = new WebSocket("wss://yourapp.example/chat");

ws.onopen  = () => ws.send(JSON.stringify({ type: "join", room: "general" }));
ws.onmessage = (event) => render(JSON.parse(event.data));

ws.onclose = () => {
  // Unlike SSE, NOTHING reconnects for you. You must do it yourself.
  setTimeout(connect, 1000);
};

What just happened: You can send() to the server and receive via onmessage, both directions, any time — that's the full-duplex superpower SSE doesn't have. But notice onclose: WebSockets give you no automatic reconnect. If you want it (and you do), you write the retry loop, the backoff, and the heartbeats yourself. That extra labor is the real price of WebSockets.

💡 Key point. Use wss:// (TLS), never ws://, in production — same reasoning as https. Plenty of corporate proxies also drop plain ws:// while letting wss:// through, so TLS isn't only about secrecy here, it's about the connection surviving at all.

The comparison table

Pin this. It's the whole guide compressed:

Polling Long-polling SSE WebSockets
Direction client pulls client pulls server → client both ways
Connection new each time held, then re-opened one, long-lived one, long-lived
Protocol plain HTTP plain HTTP plain HTTP upgraded from HTTP
Auto-reconnect n/a manual built in manual
Browser API fetch fetch EventSource WebSocket
Latency up to interval near-instant near-instant near-instant
Best for rare updates fallback feeds, notifications, dashboards chat, games, collaboration
Main cost wasted requests connection churn one-way only most complexity

⚠️ Don't open a connection per number. A persistent SSE or WebSocket connection has a real cost — it holds a server resource for as long as it's open. For a value that changes every few minutes, polling is genuinely the better engineering, not the lazy one. Reserve persistent connections for genuinely live data.

For builders

Start at the bottom of the table and stop the moment a row fits. Most "live" features are feeds or dashboards — that's SSE, and the browser does the hard part (reconnect, resume) for free. Only climb to WebSockets when you can point at a client→server message that has to fly upward constantly. And before either, ask whether plain polling at a sane interval is honestly good enough; very often it is, and it's the only one of the four with no persistent-connection bill.

Recap

  1. Polling asks on a timer (wasteful); long-polling holds the request open so the answer arrives the instant there's news — the honest fallback when SSE/WebSockets aren't options.
  2. SSE is a one-way text/event-stream the server keeps open; EventSource reconnects and resumes for free. Clean up on client close, and serve over HTTP/2 to dodge the 6-connection cap.
  3. WebSockets upgrade an HTTP request (the 101) into a two-way pipe — full-duplex, but you write your own reconnect, backoff, and heartbeats. Use wss://.
  4. The table picks for you: receive-only feed → SSE; both sides talk constantly → WebSockets; rare updates → polling.

You can now build any of the three. The last thing that separates a demo from production is what happens when one server becomes ten — and that's where realtime gets genuinely hard.

[
  {
    "q": "What makes long-polling feel like a push even though it uses ordinary HTTP requests?",
    "choices": [
      "It sends many requests per second",
      "The server holds the request open until it has news, so the answer arrives the moment it exists",
      "It uses a special long-polling protocol",
      "It keeps one connection open forever like SSE"
    ],
    "answer": 1,
    "explain": "Long-polling parks the request on the server until there's something to say (or a timeout), so latency is 'as soon as there's news' rather than 'next timer tick'."
  },
  {
    "q": "Why is SSE often preferred over a hand-rolled WebSocket for a one-way feed?",
    "choices": [
      "SSE is binary and therefore faster",
      "SSE supports two-way messaging",
      "The browser's EventSource reconnects automatically and can resume via Last-Event-ID, so you write almost nothing",
      "WebSockets can't carry JSON"
    ],
    "answer": 2,
    "explain": "For one-way data, EventSource handles reconnect and resume for free. WebSockets give you no automatic reconnect — you'd build it yourself."
  },
  {
    "q": "What does the HTTP 101 Switching Protocols response signify in a WebSocket setup?",
    "choices": [
      "An error opening the stream",
      "The server agreed to upgrade the HTTP connection into a two-way WebSocket pipe",
      "The client must poll instead",
      "The TLS handshake completed"
    ],
    "answer": 1,
    "explain": "A WebSocket starts as an HTTP request asking to upgrade; the server's 101 Switching Protocols confirms it, and the connection becomes full-duplex from then on."
  }
]

← Phase 1: Why HTTP Can't Push · Guide overview · Phase 3: When It Breaks at Scale →

Check your understanding 3 questions

1. What makes long-polling feel like a push even though it uses ordinary HTTP requests?

2. Why is SSE often preferred over a hand-rolled WebSocket for a one-way feed?

3. What does the HTTP 101 Switching Protocols response signify in a WebSocket setup?

Was this page helpful?