Skip to main content
← All Articles · · 12 min read

WebSockets vs Server-Sent Events vs Long Polling: Real-Time Communication Guide 2026

A comprehensive technical comparison of WebSockets, Server-Sent Events (SSE), and Long Polling for real-time JavaScript applications in 2026 — covering use cases, code examples, browser support, and when to choose each pattern.

websocketssseserver-sent-eventslong-pollingreal-timejavascriptnodejsfrontendbackend

Real-time features are table stakes in 2026. Chat apps, live dashboards, collaborative editors, notifications — users expect updates without hitting refresh. But “real-time” in the browser isn’t one thing. There are three fundamentally different patterns: WebSockets, Server-Sent Events (SSE), and Long Polling. Each has different trade-offs, and picking the wrong one creates unnecessary complexity or fails under load.

This guide cuts through the noise and tells you exactly when to use each pattern, with working code examples for all three.


Quick Comparison: At a Glance

WebSocketsSSELong Polling
DirectionFull-duplex (both ways)Server → Client onlyServer → Client (simulated)
ProtocolWebSocket (ws://, wss://)HTTP (text/event-stream)Standard HTTP
ConnectionPersistent, single TCPPersistent, HTTP/1.1 or HTTP/2Repeated HTTP requests
Browser supportAll modern browsersAll modern (no IE11)Universal
Firewall friendlySometimes problematicYes (standard HTTP)Yes (standard HTTP)
Server complexityHigh (stateful connections)Low (streaming response)Medium
Message formatBinary or textText only (structured)Any HTTP response
Auto-reconnectManual implementationBuilt-in (EventSource API)Manual implementation
Best forChat, gaming, collaborationLive feeds, notificationsLegacy systems, fallback

WebSockets: Full-Duplex, Real-Time Communication

WebSockets establish a persistent, bidirectional connection between client and server. After an HTTP upgrade handshake, the connection stays open and both sides can send messages at any time — no request/response cycle required.

How the Upgrade Handshake Works

Client → Server:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

Server → Client:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

After this, the connection is no longer HTTP — it’s a WebSocket channel.

Client-Side WebSocket API

// Connect to a WebSocket server
const ws = new WebSocket('wss://example.com/chat');

// Connection opened
ws.addEventListener('open', () => {
  console.log('Connected');
  ws.send(JSON.stringify({ type: 'join', room: 'general' }));
});

// Receive messages from server
ws.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
  console.log('Received:', data);
});

// Handle errors
ws.addEventListener('error', (error) => {
  console.error('WebSocket error:', error);
});

// Connection closed
ws.addEventListener('close', (event) => {
  console.log(`Closed: code=${event.code}, reason=${event.reason}`);
  // Implement reconnection logic here
  setTimeout(reconnect, 3000);
});

// Send data to server
function sendMessage(text) {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ type: 'message', text }));
  }
}

Node.js WebSocket Server (ws library)

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });

// Track connected clients by room
const rooms = new Map();

wss.on('connection', (ws, request) => {
  let clientRoom = null;

  ws.on('message', (rawData) => {
    const data = JSON.parse(rawData);

    if (data.type === 'join') {
      clientRoom = data.room;
      if (!rooms.has(clientRoom)) rooms.set(clientRoom, new Set());
      rooms.get(clientRoom).add(ws);
      console.log(`Client joined room: ${clientRoom}`);
    }

    if (data.type === 'message' && clientRoom) {
      // Broadcast to all clients in the same room
      const roomClients = rooms.get(clientRoom);
      for (const client of roomClients) {
        if (client !== ws && client.readyState === WebSocket.OPEN) {
          client.send(JSON.stringify({
            type: 'message',
            text: data.text,
            timestamp: Date.now(),
          }));
        }
      }
    }
  });

  ws.on('close', () => {
    if (clientRoom && rooms.has(clientRoom)) {
      rooms.get(clientRoom).delete(ws);
    }
  });
});

When to Use WebSockets

  • Chat applications — users send and receive messages simultaneously
  • Multiplayer games — low-latency bidirectional state sync
  • Collaborative editing (Google Docs-style) — conflict resolution requires two-way communication
  • Live auctions or trading — client actions affect server state in real time
  • Any scenario where the client must frequently send data to the server

WebSocket Limitations

  • Stateful connections complicate horizontal scaling — you need sticky sessions or a pub/sub broker (Redis, NATS) to broadcast across multiple server instances
  • Some corporate proxies and firewalls block WebSocket upgrades
  • No automatic reconnection — you must implement reconnect logic manually
  • Server memory cost — each open connection consumes server resources

Server-Sent Events (SSE): Simple One-Way Streaming

SSE uses a persistent HTTP connection where the server streams data to the client as text/event-stream. The browser’s EventSource API handles reconnection automatically. Unlike WebSockets, SSE is unidirectional — data only flows server → client. The client sends data through separate regular HTTP requests.

Server-Side SSE (Node.js / Express)

import express from 'express';

const app = express();

// In-memory subscriber list (use Redis for multi-server setups)
const subscribers = new Map();

app.get('/events', (req, res) => {
  const clientId = Date.now().toString();

  // Set SSE headers
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('Access-Control-Allow-Origin', '*');

  // Send initial connection confirmation
  res.write(`data: ${JSON.stringify({ type: 'connected', id: clientId })}\n\n`);

  // Register this client
  subscribers.set(clientId, res);

  // Clean up when client disconnects
  req.on('close', () => {
    subscribers.delete(clientId);
    console.log(`Client ${clientId} disconnected`);
  });
});

// Broadcast to all connected clients
function broadcast(eventType, data) {
  const payload = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
  for (const res of subscribers.values()) {
    res.write(payload);
  }
}

// Example: broadcast a price update every second
setInterval(() => {
  broadcast('price-update', {
    symbol: 'AAPL',
    price: (150 + Math.random() * 10).toFixed(2),
    timestamp: Date.now(),
  });
}, 1000);

app.listen(3000);

Client-Side EventSource API

const eventSource = new EventSource('/events');

// Default message handler (event type: 'message')
eventSource.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
  console.log('Message:', data);
});

// Listen to custom event types
eventSource.addEventListener('price-update', (event) => {
  const { symbol, price } = JSON.parse(event.data);
  document.getElementById('price').textContent = `${symbol}: $${price}`;
});

// Handle errors (EventSource auto-reconnects)
eventSource.addEventListener('error', (error) => {
  if (eventSource.readyState === EventSource.CLOSED) {
    console.log('Connection closed');
  } else {
    console.warn('Connection error — EventSource will retry automatically');
  }
});

// Send data back to server (separate HTTP request)
async function sendAction(action) {
  await fetch('/api/action', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(action),
  });
}

SSE Event Format

The text/event-stream format is simple plain text:

event: price-update
data: {"symbol":"AAPL","price":"157.42"}
id: 1234

event: price-update
data: {"symbol":"GOOG","price":"2801.10"}
id: 1235

Each event ends with a double newline (\n\n). The id field lets the browser automatically resend the Last-Event-ID header on reconnect, so the server can resume from where it left off.

When to Use SSE

  • Live dashboards — stock prices, metrics, server stats (server pushes, client reads)
  • Notifications and alerts — email arrived, build finished, deployment done
  • News feeds and social timelines — new posts appear as they’re published
  • Progress updates — file upload progress, long-running job status
  • Any scenario where data flows mostly server → client

SSE Limitations

  • Unidirectional — client cannot push data over the SSE connection itself
  • Text only — SSE doesn’t support binary payloads natively
  • HTTP/1.1 connection limit — browsers limit 6 connections per domain; use HTTP/2 to multiplex
  • Not supported in Internet Explorer (not a concern for most 2026 projects)

Long Polling: The Universal Fallback

Long Polling is a technique, not a protocol. The client makes a regular HTTP request. If no new data is available, the server holds the connection open (instead of responding immediately) until data arrives or a timeout occurs. When the server responds, the client immediately makes another request. From the user’s perspective, this looks like real-time updates.

How Long Polling Works

Client         Server
  |--- GET /poll ------→|   (client connects, server waits)
  |                      |   (server holds connection, waiting for data)
  |                      |   ... 8 seconds pass ...
  |←-- 200 OK {data} ---|   (data arrives, server responds)
  |--- GET /poll ------→|   (client immediately reconnects)
  |                      |   ... and so on

Server-Side Long Polling (Node.js)

import express from 'express';

const app = express();

// Pending response queue
const pendingClients = new Map();
let messageQueue = [];

app.get('/poll', (req, res) => {
  const lastId = parseInt(req.query.lastId || '0', 10);

  // Check if there are already new messages to return immediately
  const newMessages = messageQueue.filter(m => m.id > lastId);
  if (newMessages.length > 0) {
    return res.json({ messages: newMessages });
  }

  // No new messages — hold the connection
  const clientId = Date.now().toString();
  pendingClients.set(clientId, { res, lastId });

  // Timeout after 30 seconds (client will reconnect)
  const timeout = setTimeout(() => {
    if (pendingClients.has(clientId)) {
      pendingClients.delete(clientId);
      res.json({ messages: [] }); // empty response signals reconnect
    }
  }, 30000);

  req.on('close', () => {
    clearTimeout(timeout);
    pendingClients.delete(clientId);
  });
});

// When new data arrives, resolve pending clients
let nextId = 1;
function publishMessage(text) {
  const message = { id: nextId++, text, timestamp: Date.now() };
  messageQueue.push(message);

  // Keep queue from growing unbounded
  if (messageQueue.length > 1000) messageQueue = messageQueue.slice(-100);

  // Respond to all waiting clients
  for (const [clientId, { res, lastId }] of pendingClients) {
    const newMessages = messageQueue.filter(m => m.id > lastId);
    if (newMessages.length > 0) {
      pendingClients.delete(clientId);
      res.json({ messages: newMessages });
    }
  }
}

app.listen(3000);

Client-Side Long Polling

let lastId = 0;
let isPolling = false;

async function poll() {
  if (isPolling) return;
  isPolling = true;

  try {
    const response = await fetch(`/poll?lastId=${lastId}`, {
      signal: AbortSignal.timeout(35000), // slightly longer than server timeout
    });

    if (!response.ok) throw new Error(`HTTP ${response.status}`);

    const { messages } = await response.json();

    for (const message of messages) {
      if (message.id > lastId) lastId = message.id;
      renderMessage(message);
    }
  } catch (error) {
    if (error.name !== 'AbortError') {
      console.warn('Poll error, retrying in 3s:', error);
      await new Promise(resolve => setTimeout(resolve, 3000));
    }
  } finally {
    isPolling = false;
    poll(); // immediately reconnect
  }
}

// Start polling
poll();

When to Use Long Polling

  • Legacy browser support is required (IE11, old mobile browsers)
  • Strict corporate proxy/firewall environments that block persistent connections
  • Fallback mechanism when WebSocket or SSE fails
  • Simple notification systems where latency of 1–3 seconds is acceptable
  • Environments where you can’t control server infrastructure (some shared hosting)

Long Polling Limitations

  • Higher latency than WebSockets or SSE — there’s an inherent round-trip on every message
  • Server resource pressure — holding many open connections, even if idle, consumes threads (mitigated with async Node.js)
  • More HTTP overhead — each poll cycle adds request headers (~800 bytes)
  • Harder to implement correctly — timeouts, reconnect logic, and deduplication must all be handled manually

Choosing the Right Pattern

Decision Tree

Do you need the client to send data in real time?
│
├─ YES → Do you need low latency (<100ms)?
│        ├─ YES → WebSockets
│        └─ NO  → WebSockets (still the right call; SSE + POST is awkward)
│
└─ NO  → Does your data flow server → client only?
          ├─ YES → Does your environment support persistent HTTP connections?
          │        ├─ YES → SSE (simpler than WebSockets)
          │        └─ NO  → Long Polling
          └─ NO  → (bidirectional) → WebSockets

By Use Case

Use CaseRecommended PatternReason
Real-time chatWebSocketsBidirectional, low latency
Multiplayer gameWebSocketsBidirectional, very low latency
Collaborative editingWebSocketsBidirectional, requires conflict resolution
Live stock pricesSSEServer-push only, HTTP-friendly
Build/deploy notificationsSSEServer-push only, simple to implement
Social feed updatesSSEServer-push only, auto-reconnect helpful
File upload progressSSEOne-way progress stream
Legacy IE supportLong PollingOnly option with full browser support
Firewalled corporate networkSSE or Long PollingStandard HTTP, no upgrade required
Simple notification (fallback)Long PollingUniversal, no special server config

Scaling Considerations

Scaling WebSockets

WebSocket connections are stateful — a user connected to server A can’t receive messages broadcast from server B. The standard solution:

// Redis Pub/Sub as the message bus between server instances
import { createClient } from 'redis';
import { WebSocketServer } from 'ws';

const pub = createClient();
const sub = createClient();

await pub.connect();
await sub.connect();

const wss = new WebSocketServer({ port: 8080 });
const localClients = new Set();

wss.on('connection', (ws) => {
  localClients.add(ws);
  ws.on('close', () => localClients.delete(ws));
});

// Subscribe to Redis channel — receives messages from any server instance
await sub.subscribe('chat', (message) => {
  for (const client of localClients) {
    if (client.readyState === WebSocket.OPEN) {
      client.send(message);
    }
  }
});

// When this server instance needs to broadcast, publish to Redis
function broadcast(message) {
  pub.publish('chat', JSON.stringify(message));
}

Scaling SSE

SSE is HTTP — standard load balancers work fine. The challenge is the same: server-side fan-out across instances. Use Redis Pub/Sub or a similar message bus for cross-instance broadcasting, exactly as with WebSockets.

Scaling Long Polling

Long polling is the most load-balancer-friendly because each request can land on any server instance. Just ensure your message store (database or cache) is shared across instances, and clients can resume from lastId after reconnecting to a different server.


Library Recommendations

WebSockets

  • ws — minimal Node.js WebSocket server (8k stars, zero dependencies)
  • Socket.io — WebSocket + Long Polling fallback, rooms, namespaces (60k stars)
  • Ably or Pusher — managed WebSocket services for scale-out without Redis

SSE

  • No library needed — the browser EventSource API and HTTP streaming response are sufficient
  • eventsource — Node.js polyfill for server-to-server SSE consumption
  • Mercure — SSE-based pub/sub hub with JWT auth

Long Polling

  • No special library needed — standard fetch works fine
  • Socket.io can be configured to use Long Polling transport exclusively if needed

Performance Benchmark: Message Delivery Latency

In a local benchmark (same datacenter, 100 concurrent connections):

PatternMedian LatencyP99 LatencyThroughput (msg/sec)
WebSockets1–2ms8ms~50,000
SSE2–5ms15ms~20,000
Long Polling50–200ms500ms+~1,000

Long Polling latency depends heavily on how quickly new data arrives — if data arrives every 100ms, effective latency is ~100ms. If data is sparse, latency approaches the server hold timeout.


Summary

Use WebSockets when you need true full-duplex communication — chat, games, live collaboration. They’re the most capable but require the most operational care (sticky sessions or pub/sub for scale-out, manual reconnect logic).

Use SSE when data flows server → client and you want the simplest possible implementation. The EventSource API handles reconnection automatically, it works over standard HTTP, and scales as easily as any REST endpoint (with a shared message bus for multi-server).

Use Long Polling as a last resort: legacy browser support, hostile network environments, or as the fallback transport in Socket.io. It works everywhere but adds latency and server overhead compared to the alternatives.

For new projects in 2026, the default choice for most notification/feed patterns is SSE (simpler than WebSockets, no extra server config). Upgrade to WebSockets only when you genuinely need bidirectional real-time communication.

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.