Wise Hustlers — Digital Product & App Development Studio Logo
Get Consultation
By Wise Hustler Admin•9/19/2026•9 min read

WebSockets vs Server-Sent Events vs Polling: Choosing the Right Real-Time Transport

WebSockets vs Server-Sent Events vs Polling: Choosing the Right Real-Time Transport

# WebSockets vs Server-Sent Events vs Polling: Choosing the Right Real-Time Transport

TL;DR: Use polling for infrequent updates where simplicity wins, Server-Sent Events for one-way server-to-client streams (notifications, live feeds, LLM token streaming) because it rides plain HTTP and survives corporate proxies, and WebSockets only when you genuinely need bidirectional, low-latency traffic (chat, multiplayer, collaborative editing) — and be ready to handle the proxies that will occasionally block it.

Every "real-time" feature request eventually runs into the same question: what transport actually moves the data? Teams often reach for WebSockets by default because the name sounds real-time-native, then spend weeks debugging why a subset of users behind corporate VPNs never receive updates. The right choice depends on three things that get glossed over in most comparisons: which direction data needs to flow, how much connection overhead you can afford per client, and whether the transport survives the proxies and firewalls sitting between your server and the browser.

The three options, briefly

Polling (plain or long) is the browser repeatedly asking "anything new?" over regular HTTP requests. Long polling holds the request open server-side until there's data or a timeout, which reduces empty responses compared to short-interval polling but is still fundamentally request/response.

Server-Sent Events (SSE) is a single long-lived HTTP connection where the server streams text/event-stream formatted messages to the client. It's part of the HTML Living Standard and implemented natively via the EventSource API in every major browser (WHATWG HTML spec). On the server side, Node.js has a browser-compatible EventSource global, but it is still marked experimental and needs the --experimental-eventsource flag (Node.js globals docs); otherwise you use an HTTP client and parse the stream, or a small library.

WebSockets (RFC 6455) upgrade a single HTTP connection to a full-duplex TCP-like channel using the Upgrade: websocket handshake. Once upgraded, either side can send frames at any time, independent of request/response semantics (RFC 6455).

Comparison table: directionality, overhead, and proxy compatibility

DimensionPolling (short/long)Server-Sent EventsWebSockets
DirectionalityClient-initiated request/response only; client can't receive unsolicited pushesOne-way, server → client only. Client sends data via a normal HTTP request on a separate connectionFull-duplex; either side sends at any time on the same connection
Connection overheadNew TCP+TLS handshake (or reused keep-alive) per poll interval; HTTP headers resent every requestOne persistent HTTP connection per client; headers sent once, then just the stream. Over HTTP/1.1, counts against the browser's limit of 6 connections per browser + domain (Chrome and Firefox); over HTTP/2 the streams share one connection, with the concurrent-stream limit negotiated (default 100) (MDN: EventSource)One persistent TCP connection per client, held open for the session; lowest per-message overhead (2–14 byte frame headers vs full HTTP headers, per RFC 6455 §5.2) but the priciest to keep open at scale
Reconnect/resumeN/A — every poll is independent, so there's nothing to resumeBuilt into the browser: auto-reconnects and resends Last-Event-ID so the server can replay missed eventsNo built-in reconnection or message-id replay; you implement it yourself (sequence numbers, resend buffers)
Proxy/firewall compatibilityBest — it's indistinguishable from normal page requests to any intermediaryVery good — plain HTTP/HTTPS, no Upgrade header, so it passes through proxies and corporate filters that don't understand upgrades. The main failure mode is an intermediary that buffers the response, which delays events rather than blocking themWeakest — depends on the proxy correctly forwarding the Upgrade/Connection headers; transparent proxies, some corporate TLS-terminating gateways, and strict deep-packet-inspection firewalls can break it even on port 443
Browser APIfetch/XMLHttpRequestEventSource (native, auto-parses the event stream)WebSocket (native, manual message framing/JSON)
Typical use caseDashboards refreshing every N minutes, status checksNotification feeds, live scores, AI token streaming, log tailingChat, multiplayer games, collaborative editors, trading terminals

What "connection overhead" actually costs you

The overhead difference matters most at scale, in two different ways.

Per-request overhead (polling): every poll resends the full HTTP header set — cookies, auth headers, User-Agent, etc. — which for a request polling every 3 seconds against a JWT-authenticated API adds up to real bandwidth even when nothing changed. Long polling reduces the frequency of empty responses but doesn't reduce this per-request header cost when a response does come back.

Per-connection overhead (SSE and WebSockets): the cost shifts from bandwidth to held-open server resources — a socket, buffers, and (for WebSockets) frequently an application-level session object per connection. Idle WebSocket or SSE connections are mostly idle sockets, so the first limits you hit are usually memory per connection, your load balancer's connection limits, and your server's file descriptor limits rather than CPU — benchmark your own stack to find the real number. This is also why SSE over HTTP/1.1 has a sharp, easy-to-hit ceiling: browsers allow only 6 connections per browser + domain, shared across all tabs, and Chrome and Firefox have marked the issue "Won't fix" (MDN: EventSource). Open your SSE-using app in six tabs and every connection to that domain is taken — a seventh tab's stream, and ordinary requests from the open tabs, queue behind them. Serving SSE over HTTP/2 removes this ceiling because the protocol multiplexes many streams over one TCP connection; the concurrent-stream limit is negotiated, and RFC 9113 recommends it be no smaller than 100 (RFC 9113 §6.5.2).

SSE example: streaming updates from an Express/Node API

// server.js — SSE endpoint
app.get('/api/events', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    Connection: 'keep-alive',
    // Prevents nginx from buffering the stream when proxied
    'X-Accel-Buffering': 'no',
  });

  const lastEventId = req.headers['last-event-id'];
  if (lastEventId) {
    // Replay events the client missed since it disconnected
    replayEventsSince(lastEventId).forEach((evt) => sendEvent(res, evt));
  }

  const unsubscribe = subscribeToOrderUpdates((evt) => sendEvent(res, evt));

  req.on('close', () => unsubscribe());
});

function sendEvent(res, evt) {
  res.write(`id: ${evt.id}\n`);
  res.write(`event: order-update\n`);
  res.write(`data: ${JSON.stringify(evt.payload)}\n\n`);
}
// client.js
const source = new EventSource('/api/events');

source.addEventListener('order-update', (e) => {
  const payload = JSON.parse(e.data);
  updateOrderStatus(payload);
});

source.onerror = () => {
  // EventSource auto-reconnects and resends Last-Event-ID automatically;
  // this fires on every drop, including the ones it recovers from
  console.warn('SSE connection dropped, browser will retry');
};

If this sits behind nginx, disable response buffering for the route or the stream will queue up server-side instead of flushing immediately:

location /api/events {
    proxy_pass http://localhost:3000;
    proxy_buffering off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding off;
}

Where WebSockets over HTTP/2 stand today

RFC 8441 defines running WebSockets inside a single HTTP/2 stream via extended CONNECT, which would let a WebSocket share a connection with other HTTP/2 traffic instead of needing its own TCP connection. Browser support exists: Firefox shipped it in version 65 (Mozilla bug 1434137), and Chrome also uses extended CONNECT when the server advertises support. The gaps are on the server and proxy side — nginx, for example, still has no RFC 8441 support and its maintainers have said it is "unlikely to happen unless there is a practical demand" (nginx ticket #1992) — so browsers fall back to HTTP/1.1 Upgrade whenever the server doesn't advertise it, and misconfigured servers that advertise it without handling it properly have broken WebSockets for real products (Mattermost issue #30285). Unless you've verified RFC 8441 end to end through every proxy and load balancer in your path, don't design around HTTP/2 WebSocket multiplexing as a solution to connection-count problems; plan for one TCP connection per WebSocket client.

Practical decision guide

  • Data only flows server → client, and clients could be behind restrictive corporate networks: SSE. It's plain HTTPS, survives proxies that strip Upgrade headers, and gives you reconnection/replay for free.
  • You need true bidirectional, low-latency messaging (cursors, game state, chat): WebSockets, but budget engineering time for reconnection logic, heartbeats, and a fallback path for the minority of networks that block the upgrade.
  • Updates are infrequent (minutes, not seconds) or the client only needs current state, not a stream of deltas: polling. It's the least to build, debug, and operate, and it's the most proxy-friendly by construction.
  • You're building this against a third-party API you don't control: check what the API actually offers before designing your own transport layer — many "real-time" third-party integrations only expose webhooks or polling endpoints regardless of what your frontend would prefer. If you're stitching together several such APIs, that's the kind of work our API integrations team offers.

FAQ

Can I use Server-Sent Events for chat or anything requiring client-to-server messages?

Yes, but the client-to-server leg goes over a normal fetch/XHR POST request on a separate HTTP connection, not the SSE stream itself. This works fine for chat (POST a message, receive it back via SSE broadcast) but adds one extra round trip per message compared to a WebSocket sending on the same connection it's receiving on.

Why did my WebSocket connection work locally but fail for some users in production?

The most common cause is an intermediary — a corporate proxy, a captive portal, or a security appliance doing TLS inspection — that doesn't correctly forward the Upgrade/Connection handshake headers or blocks non-HTTP-looking traffic on port 443 even though the client requested wss://. Always implement a fallback (SSE or polling) rather than assuming WebSocket support is universal.

Does HTTP/2 fix the browser's 6-connections-per-origin limit for SSE?

Yes. That limit applies when SSE isn't served over HTTP/2; HTTP/2's multiplexing lets many SSE streams (and other requests) share one TCP connection to the same origin, with the stream limit negotiated between client and server (MDN gives 100 as the default, and RFC 9113 recommends no fewer than 100).

Is Socket.IO the same thing as raw WebSockets?

No. By default a Socket.IO client connects with HTTP long-polling first and then tries to upgrade to WebSocket (or WebTransport), staying on long-polling if the upgrade fails; it also adds its own framing, acknowledgements, rooms, and reconnection logic on top (Socket.IO: How it works). That handles the proxy-compatibility problem WebSockets have, at the cost of a heavier client library and a protocol a plain WebSocket client can't talk to.

Sources

Related articles