Architecture Patterns

Long Polling, WebSockets & SSE

● Intermediate ⏱ 12 min read architecture

Standard HTTP is a request-response protocol: the client asks, the server answers, the connection closes. This works perfectly for loading pages and fetching data on demand, but it’s fundamentally ill-suited for real-time scenarios where the server needs to push updates to the client — live notifications, chat messages, stock prices, or collaborative editing. Three techniques bridge this gap: long polling, Server-Sent Events, and WebSockets.

The Problem: HTTP Is Pull

In standard HTTP, the server can only send data in response to a client request. The server can’t initiate communication. For a chat application, this means the client can’t know about a new message until it asks. If it asks once per second, it makes 86,400 requests per day per user — mostly empty responses. If it asks every 30 seconds, messages arrive with up to 30 seconds of latency.

All three techniques below are strategies for working around this fundamental constraint, each with different latency, complexity, and infrastructure cost trade-offs.

Short Polling

The simplest approach: the client sends a request every N seconds and the server responds immediately, even if there’s nothing new.

// Client polls every 5 seconds
setInterval(async () => {
  const res = await fetch('/api/notifications');
  const data = await res.json();
  if (data.notifications.length) renderNotifications(data);
}, 5000);

Short polling is easy to implement and works with any HTTP infrastructure. Its problems are obvious: at 1-second intervals, 99% of responses are empty waste. At 5-second intervals, latency is up to 5 seconds. It doesn’t scale — N active users generate N requests per second regardless of whether anything changed.

Short polling is acceptable for infrequently-updating data (checking a background job’s status) or when the polling interval matches the natural update cadence. It’s not suitable for real-time features.

Long Polling

Long polling improves on short polling: the client sends a request, and the server holds the connection open until it has data to send (or a timeout expires). When the server responds, the client immediately sends the next request.

// Client — long polling loop
async function longPoll() {
  const res = await fetch('/api/events?lastEventId=' + lastId);
  const data = await res.json();
  processEvents(data.events);
  lastId = data.lastEventId;
  longPoll(); // immediately re-poll
}
longPoll();
// Server — hold until event available
app.get('/api/events', async (req, res) => {
  const event = await waitForEvent(req.query.lastEventId, timeout=30s);
  if (event) res.json({ events: [event], lastEventId: event.id });
  else res.json({ events: [], lastEventId: req.query.lastEventId });
});

Advantages: Near-real-time delivery (latency equals server processing time, not polling interval). Works with standard HTTP — no special infrastructure. Firewall and proxy friendly.

Disadvantages: Each long-poll request holds a server thread or connection. With 10,000 concurrent users, 10,000 connections are held open — expensive unless the server uses async I/O (Node.js, async Python, Go goroutines). Reconnect overhead on every event. Message ordering and deduplication must be managed with event IDs.

Long polling was the standard technique before WebSockets were widely supported. It’s still used in environments where WebSocket connections are blocked by proxies or firewalls, and by some systems as a fallback.

Server-Sent Events (SSE)

SSE is a browser standard for server-to-client streaming over a single, long-lived HTTP connection. The client opens one connection; the server streams events as they occur, indefinitely.

// Client
const source = new EventSource('/api/stream');
source.onmessage = (event) => {
  const data = JSON.parse(event.data);
  renderUpdate(data);
};
source.addEventListener('notification', (event) => {
  showNotification(JSON.parse(event.data));
});
// Server (Node.js)
app.get('/api/stream', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');

  const sendEvent = (type, data) => {
    res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
  };

  const unsubscribe = eventBus.subscribe(req.user.id, sendEvent);
  req.on('close', unsubscribe); // cleanup when client disconnects
});

SSE uses the text/event-stream content type with a simple line-based protocol. Each event is delimited by a blank line and can carry an event type, data, and an ID for reconnection. The browser automatically reconnects with Last-Event-ID if the connection drops.

Advantages: Simple protocol built on HTTP — works through proxies and load balancers that handle HTTP streaming. Native browser support (EventSource API) with automatic reconnection. One-directional by design — appropriate for notification feeds, live logs, progress updates.

Disadvantages: Server-to-client only — the client cannot send data on the same connection. For bidirectional communication, the client must use a separate HTTP request for sending. Maximum 6 concurrent SSE connections per domain in HTTP/1.1 (not a limitation with HTTP/2).

WebSockets

WebSockets provide a persistent, full-duplex communication channel between client and server over a single TCP connection. The connection starts as HTTP, then upgrades to the WebSocket protocol via the Upgrade header. After the handshake, either side can send messages at any time.

// Client
const ws = new WebSocket('wss://example.com/ws');

ws.onopen = () => ws.send(JSON.stringify({ type: 'subscribe', channel: 'orders' }));
ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  handleMessage(msg);
};
ws.onclose = () => scheduleReconnect();
// Server (Node.js with ws library)
wss.on('connection', (socket, req) => {
  const userId = authenticate(req);
  socket.on('message', (raw) => {
    const msg = JSON.parse(raw);
    if (msg.type === 'subscribe') subscribeToChannel(userId, msg.channel, socket);
    if (msg.type === 'message') broadcastMessage(msg);
  });
  socket.on('close', () => cleanup(userId));
});

Advantages: True bidirectional communication — both client and server can send at any time. Lowest latency of the three techniques — no request overhead after the initial handshake. Ideal for chat, multiplayer games, collaborative editing, and live trading.

Disadvantages: Stateful connections complicate horizontal scaling — a user’s WebSocket is pinned to one server. A shared pub-sub layer (Redis, Kafka) broadcasts events to the correct server instance. Load balancers must support WebSocket (sticky sessions or Layer 7 WebSocket routing). More complex to implement than SSE, especially reconnection and heartbeating logic.

Comparison

Short PollingLong PollingSSEWebSockets
DirectionClient pullClient pullServer pushBidirectional
LatencyPoll intervalNear real-timeNear real-timeReal-time
Connections heldNone (req/resp)One per clientOne per clientOne per client
ProtocolHTTPHTTPHTTP (streaming)WS (TCP upgrade)
Browser supportUniversalUniversalAll modern browsersAll modern browsers
Proxy/firewallNo issuesNo issuesUsually fineSometimes blocked
ComplexityLowestLowLow-MediumMedium-High
Best forInfrequent updatesFallback / constrained envNotifications, feedsChat, games, collaboration
Scaling WebSocket Servers

WebSocket connections are long-lived and stateful — a user stays connected to one server. When you scale horizontally to 10 servers, a message for User A must reach the server that holds User A’s connection. The standard solution: a pub-sub backplane (Redis Pub/Sub, Kafka) that each WebSocket server subscribes to. When any server needs to send to User A, it publishes to the backplane; the server holding User A’s connection receives it and forwards it down the socket.

Design Considerations