Saikat
← Back to blog

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

July 19, 2026·10 min read
WebSocketsSSEReal-TimeNestJS
Share:
WebSockets vs Server-Sent Events vs Polling: Choosing Real-Time Transport

"Make it real-time" is one of those requirements that sounds like a single feature but is actually a transport decision. Every time it comes up, someone immediately says "WebSockets," and every time I have to slow the conversation down — because WebSockets are the most powerful option and the most expensive one to run properly, and probably half of the "real-time" features I've shipped didn't need them at all.

There are really four options: short polling, long polling, Server-Sent Events, and WebSockets. They form a ladder — each rung buys you lower latency and a more natural push model, and each rung costs you more infrastructure complexity. The trick is stepping onto the lowest rung that satisfies the feature, not the highest one the team finds exciting.

Short polling: the baseline everyone forgets is an option

Short polling is just a GET on a timer:

setInterval(async () => {
  const res = await fetch('/api/orders/42/status');
  updateUi(await res.json());
}, 5000);

Every request is a completely ordinary HTTP request. That single fact is the source of all of polling's virtues: it works through every proxy, CDN, and corporate firewall ever built; auth is your normal Authorization header; there's no connection state on the server; "reconnection" is a non-concept because there's nothing to reconnect. A crashed server, a redeploy, a load balancer failover — the client never notices.

The cost is the obvious one: most requests return nothing new. At a 5-second interval, a thousand idle clients generate 200 requests per second whose answer is "no change." You pay for that in server CPU, in database load if the check isn't cached, and in latency — the average update arrives half an interval late.

Long polling: polling that pretends to push

Long polling flips the waiting to the server side. The client sends a request; the server holds it open until there's something to say (or a timeout, typically 25–30 seconds), responds, and the client immediately reconnects. Latency drops to near-zero because the response fires the moment the event happens, and idle clients cost one held connection instead of a stream of no-op requests.

It's how a lot of chat systems worked before WebSockets existed, and it still shows up in messaging queues and third-party APIs. But it's the awkward middle child: you hold server connections open like WebSockets, yet still pay per-request overhead and re-auth on every cycle, and intermediaries with aggressive idle timeouts (some proxies cut connections at 30–60 seconds) will bite you. Today I only reach for it when I need push-like latency in an environment where SSE and WebSockets are both blocked — which is rare.

Server-Sent Events: the underrated one

SSE is the option most teams skip over, and it's usually the one they should have picked. It's a single long-lived HTTP response with Content-Type: text/event-stream, over which the server writes events as plain text, one after another:

event: status
id: 17
data: {"orderId":42,"status":"shipped"}

Because it's just an HTTP response, most infrastructure handles it with minor configuration (mainly: disable response buffering — X-Accel-Buffering: no for Nginx). And the browser API, EventSource, does something neither polling nor raw WebSockets give you for free: automatic reconnection with resume. If the connection drops, the browser reconnects on its own and sends the last event id it saw in a Last-Event-ID header, so the server can replay what was missed. That's a feature you'd write hundreds of lines of client code to replicate on WebSockets.

The limits are real, though. SSE is strictly one-directional — server to client. Client-to-server communication happens over ordinary HTTP requests alongside the stream, which is honestly fine for most features. The sharper edge is auth: EventSource cannot set custom headers, so you authenticate with cookies, a query-string token, or a wrapper like fetch-event-source that speaks the protocol over fetch. And on HTTP/1.1, browsers cap you at ~6 connections per domain, so a user with seven tabs open breaks — serve SSE over HTTP/2 and this problem disappears.

WebSockets: full duplex, full responsibility

A WebSocket starts life as an HTTP request with an Upgrade: websocket header. After the 101 Switching Protocols handshake, HTTP is gone — you have a raw, persistent, bidirectional TCP channel where either side sends frames at any time. It's the only option here with true low-latency client-to-server push, which is why it's non-negotiable for chat with typing indicators, multiplayer cursors, collaborative editing, and games.

Leaving HTTP behind is exactly the cost. Every piece of HTTP-shaped infrastructure now needs convincing: proxies and load balancers must support the upgrade and long-lived connections, CDNs mostly won't help you, and auth happens once at the handshake — after that you own token expiry, revocation, and re-authentication yourself. There's no request/response correlation, no status codes, no Retry-After; every protocol nicety you got free with HTTP, you now design.

Head-to-head

Short polling Long polling SSE WebSockets
Direction Client pull Client pull, push-ish latency Server → client push Full duplex
Reconnection Nothing to reconnect Client re-requests naturally Automatic + Last-Event-ID resume Manual (or a library)
Proxies / CDN Perfect — plain HTTP Mostly fine; idle timeouts bite Good; disable buffering Needs upgrade support end-to-end
Auth story Standard headers, per request Standard headers, per request Cookies or query token (no custom headers) Handshake only; expiry is on you
Browser support Universal Universal All modern browsers Universal
Scaling cost Wasteful requests, stateless servers Held connections + request overhead One cheap held connection per client Stateful connections; sticky sessions or pub/sub fan-out

A WebSocket gateway in NestJS

NestJS ships first-class WebSocket support via @nestjs/websockets, with two adapters: socket.io (default) and raw ws. The distinction matters. Socket.io is not plain WebSockets — it's its own protocol layered on top, and in exchange it gives you rooms, acknowledgements, automatic reconnection, and long-polling fallback. Raw ws is standards-compliant RFC 6455, lighter, and works with any WebSocket client — but you build rooms and reconnection yourself. My rule: browser-facing product features get socket.io; anything a non-JS client must consume gets ws.

@WebSocketGateway({ namespace: '/orders', cors: { origin: process.env.CLIENT_URL } })
export class OrdersGateway implements OnGatewayConnection {
  @WebSocketServer() server: Server;

  handleConnection(client: Socket) {
    const user = verifyToken(client.handshake.auth.token);
    if (!user) return client.disconnect();
    client.data.userId = user.id;
  }

  @SubscribeMessage('order:subscribe')
  onSubscribe(@MessageBody() orderId: string, @ConnectedSocket() client: Socket) {
    client.join(`order:${orderId}`);
  }

  // Called from a service when state changes
  notifyStatus(orderId: string, status: string) {
    this.server.to(`order:${orderId}`).emit('order:status', { orderId, status });
  }
}

SSE in NestJS is almost embarrassingly small

The @Sse() decorator turns an RxJS Observable into an event stream:

@Controller('orders')
export class OrdersController {
  constructor(private readonly orders: OrdersService) {}

  @Sse(':id/events')
  events(@Param('id') id: string): Observable<MessageEvent> {
    return this.orders.statusStream(id).pipe(
      map((status) => ({ id: String(status.seq), data: status })),
    );
  }
}

And the client is three lines:

const source = new EventSource('/api/orders/42/events');
source.onmessage = (e) => updateUi(JSON.parse(e.data));
source.onerror = () => {/* browser reconnects automatically */};

No gateway, no adapter, no new protocol — it goes through the same guards-and-interceptors pipeline as the rest of your HTTP app. When the feature is "the server tells the browser about progress," this is the whole implementation.

Scaling WebSockets horizontally: the part nobody budgets for

The moment you run two instances of a WebSocket server, you hit the problem: a message emitted on instance A never reaches clients connected to instance B. Two fixes exist, and you usually need both.

Sticky sessions make the load balancer route each client to the same instance (by cookie or IP hash). Socket.io practically requires this when long-polling fallback is enabled, because the polling requests must land on the instance holding the session. But stickiness alone doesn't solve fan-out — it just keeps each client's connection coherent.

A pub/sub adapter solves fan-out by broadcasting emits through Redis, so every instance forwards the message to its own connected clients:

export class RedisIoAdapter extends IoAdapter {
  private adapterConstructor: ReturnType<typeof createAdapter>;

  async connectToRedis(): Promise<void> {
    const pubClient = createClient({ url: process.env.REDIS_URL });
    const subClient = pubClient.duplicate();
    await Promise.all([pubClient.connect(), subClient.connect()]);
    this.adapterConstructor = createAdapter(pubClient, subClient);
  }

  createIOServer(port: number, options?: ServerOptions) {
    const server = super.createIOServer(port, options);
    server.adapter(this.adapterConstructor);
    return server;
  }
}

This mirrors the broker-centric patterns I described in NestJS microservices architecture — once you scale past one node, some shared message layer becomes mandatory. Budget for it up front: Redis (or a broker) is now a hard dependency of your "simple" chat feature, and deploys get more interesting too, since every rolling restart disconnects thousands of clients that all reconnect at once. SSE has the same multi-instance fan-out question, but its automatic resume makes the reconnect storm far more forgiving.

When plain polling is honestly fine

Some features I have deliberately shipped with short polling, and regretted nothing: a dashboard refreshing metrics every 30 seconds, a build-status badge, a "check for new notifications" counter on a page users rarely keep open. The break-even math is simple — if the update frequency is low, sessions are short, and a few seconds of staleness is invisible to the user, polling's per-request waste is smaller than the standing cost of thousands of held connections plus the Redis layer to coordinate them. A cached GET behind a CDN can serve absurd polling loads for pennies. Real-time is a latency budget, not an ideology.

How I actually choose

  1. Does the client need to push messages at high frequency? Chat, cursors, games → WebSockets, and accept the sticky-sessions/Redis bill.
  2. Is it server-to-client only? Notifications, progress bars, live feeds, dashboards → SSE. This covers more "real-time" features than most teams expect.
  3. Are updates infrequent or staleness cheap?Short polling, aggressively cached. Nobody ever got paged at 3 a.m. because of polling.
  4. Is it service-to-service rather than browser-to-server? Then this whole ladder is the wrong menu — use gRPC streaming with a typed contract, as I argued in HTTP vs gRPC.
  5. Hostile network environment where everything else fails?Long polling, or socket.io with fallback enabled, which does this dance for you.

Wrapping up

The three transports (plus long polling) aren't competitors so much as points on a cost curve. Polling is maximally boring and maximally compatible. SSE keeps HTTP's operational simplicity while giving you genuine push and free reconnection — it's my default for one-way real-time. WebSockets buy full duplex at the price of owning connection state, auth lifecycle, and horizontal fan-out yourself.

So start from the feature, not the technology: write down the direction of data flow, the update frequency, and the staleness users will actually notice. Pick the lowest rung on the ladder that meets those numbers — you can always climb later, and climbing up is far easier than dismantling a WebSocket cluster that never needed to exist.