MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

WebSockets vs Server-Sent Events vs Long Polling

Compare real-time technologies: when to use each, implementation patterns, and trade-offs.

WebSocketSSEReal-TimeArchitecture

By MinhVo

Introduction

Real-time communication is essential for modern web applications — chat apps, live dashboards, collaborative editing, live sports scores, multiplayer games, and notification systems. Three technologies dominate the real-time landscape: WebSockets (bidirectional, persistent), Server-Sent Events (server-to-client streaming), and Long Polling (HTTP-compatible fallback). Each has fundamentally different trade-offs in complexity, performance, scalability, and browser support.

Choosing the wrong real-time technology can lead to scalability nightmares, unnecessary complexity, or poor user experience. This guide compares all three with production-ready implementations, performance benchmarks, and decision frameworks to help you choose the right approach for your use case.

Real-time communication

How Each Technology Works

WebSockets: Full-Duplex Communication

WebSockets establish a persistent, full-duplex TCP connection between client and server. The connection starts with an HTTP upgrade handshake — the client sends an Upgrade: websocket header, and the server responds with 101 Switching Protocols. After the handshake, both sides can send messages at any time without HTTP overhead.

The WebSocket protocol frames messages with a minimal 2-14 byte header, compared to hundreds of bytes for HTTP headers on every request. This makes WebSockets extremely efficient for high-frequency, small-message communication like real-time games or live trading platforms.

// WebSocket handshake (client-initiated)
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
 
// Server response
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Server-Sent Events: Server-to-Client Streaming

SSE uses standard HTTP to deliver a stream of events from server to client. The server holds the connection open and sends events in a simple text format: each event is prefixed with data: and terminated by a double newline. The browser's EventSource API handles connection management, automatic reconnection, and event parsing.

SSE is built on HTTP, which means it works through proxies, load balancers, and firewalls without special configuration. With HTTP/2, multiple SSE streams can share a single TCP connection through multiplexing, eliminating the HTTP/1.1 connection limit issue.

// SSE wire format
data: {"type": "message", "text": "Hello"}

event: price-update
data: {"symbol": "AAPL", "price": 150.25}

event: notification
data: {"message": "New comment on your post"}
id: 12345
retry: 5000

Long Polling: HTTP-Compatible Real-Time

Long polling simulates real-time by holding an HTTP response open until the server has data to send. The client sends a request, and the server waits (typically 30-60 seconds) before responding. Once the response arrives, the client immediately sends a new request. This creates a near-real-time experience using standard HTTP.

Long polling is the universal fallback — it works everywhere HTTP works, including environments that block WebSocket upgrades and don't support SSE. The tradeoff is higher latency (one round-trip per message) and more server resources (each held connection consumes a thread or goroutine).

Comprehensive Comparison

FeatureWebSocketServer-Sent EventsLong Polling
DirectionBidirectionalServer → ClientBidirectional
Protocolws:// / wss://HTTP/1.1, HTTP/2HTTP/1.1, HTTP/2
ConnectionPersistentPersistentRepeated requests
ComplexityHighLowMedium
Browser supportAllAll (IE: polyfill)All
Auto-reconnectManualBuilt-inManual
Binary dataYesNo (text only)Depends
Max connections~65K per server~6 per domain (HTTP/1.1)~6 per domain
Firewall friendlySometimes blockedYesYes
Message overhead2-14 bytes~30 bytesFull HTTP headers
LatencyLowestLowHigher (round-trip)
Server resourcesLow per connectionLow per connectionHigher per connection

WebSocket Implementation

Server (Node.js with ws)

const WebSocket = require('ws');
const http = require('http');
 
const server = http.createServer();
const wss = new WebSocket.Server({ server });
 
// Room-based messaging
const rooms = new Map();
 
wss.on('connection', (ws, req) => {
  const userId = new URL(req.url, 'http://localhost').searchParams.get('userId');
  ws.userId = userId;
  ws.isAlive = true;
 
  ws.on('pong', () => { ws.isAlive = true; });
 
  ws.on('message', (data) => {
    const message = JSON.parse(data);
 
    switch (message.type) {
      case 'join':
        joinRoom(ws, message.room);
        break;
      case 'message':
        broadcastToRoom(message.room, {
          type: 'message',
          from: userId,
          text: message.text,
          timestamp: Date.now(),
        }, ws);
        break;
    }
  });
 
  ws.on('close', () => {
    // Remove from all rooms
    rooms.forEach((clients, room) => {
      clients.delete(ws);
      if (clients.size === 0) rooms.delete(room);
    });
  });
 
  ws.send(JSON.stringify({ type: 'connected', userId }));
});
 
// Heartbeat to detect dead connections
const heartbeat = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!ws.isAlive) return ws.terminate();
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);
 
function joinRoom(ws, room) {
  if (!rooms.has(room)) rooms.set(room, new Set());
  rooms.get(room).add(ws);
}
 
function broadcastToRoom(room, message, exclude) {
  const clients = rooms.get(room);
  if (!clients) return;
  const data = JSON.stringify(message);
  clients.forEach((client) => {
    if (client !== exclude && client.readyState === WebSocket.OPEN) {
      client.send(data);
    }
  });
}
 
server.listen(8080);

Client with Reconnection and Heartbeat

class WebSocketClient {
  constructor(url, options = {}) {
    this.url = url;
    this.handlers = new Map();
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
    this.heartbeatInterval = options.heartbeatInterval ?? 25000;
    this.connect();
  }
 
  connect() {
    this.ws = new WebSocket(this.url);
 
    this.ws.onopen = () => {
      console.log('Connected');
      this.reconnectAttempts = 0;
      this.startHeartbeat();
    };
 
    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      const handler = this.handlers.get(data.type);
      if (handler) handler(data);
    };
 
    this.ws.onclose = (event) => {
      this.stopHeartbeat();
      if (!event.wasClean) {
        this.reconnect();
      }
    };
 
    this.ws.onerror = () => {
      // onclose will fire after onerror
    };
  }
 
  startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      if (this.ws.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ type: 'ping' }));
      }
    }, this.heartbeatInterval);
  }
 
  stopHeartbeat() {
    clearInterval(this.heartbeatTimer);
  }
 
  reconnect() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error('Max reconnection attempts reached');
      return;
    }
    const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
    console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`);
    setTimeout(() => {
      this.reconnectAttempts++;
      this.connect();
    }, delay);
  }
 
  on(type, handler) {
    this.handlers.set(type, handler);
  }
 
  send(data) {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    }
  }
 
  close() {
    this.ws.close(1000, 'Client closing');
  }
}

Server-Sent Events (SSE) Implementation

Server (Node.js)

const http = require('http');
 
const clients = new Set();
 
const server = http.createServer((req, res) => {
  if (req.url === '/events') {
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      'Access-Control-Allow-Origin': '*',
      'X-Accel-Buffering': 'no', // Disable nginx buffering
    });
 
    clients.add(res);
 
    // Send initial connection event with retry interval
    res.write('retry: 3000\n');
    res.write(`data: ${JSON.stringify({ type: 'connected', timestamp: Date.now() })}\n\n`);
 
    // Send heartbeat every 15 seconds to keep connection alive
    const heartbeat = setInterval(() => {
      res.write(': heartbeat\n\n');
    }, 15000);
 
    req.on('close', () => {
      clients.delete(res);
      clearInterval(heartbeat);
    });
  }
 
  if (req.url === '/broadcast' && req.method === 'POST') {
    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      const message = body;
      broadcast(message);
      res.writeHead(200);
      res.end('OK');
    });
  }
});
 
function broadcast(data) {
  clients.forEach(client => {
    client.write(`data: ${data}\n\n`);
  });
}
 
server.listen(8080);

Client with Custom Event Types

class SSEClient {
  constructor(url, options = {}) {
    this.url = url;
    this.handlers = new Map();
    this.options = options;
    this.connect();
  }
 
  connect() {
    this.eventSource = new EventSource(this.url);
 
    this.eventSource.onopen = () => {
      console.log('SSE connected');
      if (this.handlers.has('connected')) {
        this.handlers.get('connected')();
      }
    };
 
    this.eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data);
      const handler = this.handlers.get(data.type);
      if (handler) handler(data);
    };
 
    this.eventSource.onerror = () => {
      if (this.eventSource.readyState === EventSource.CLOSED) {
        console.log('SSE connection closed permanently');
      } else {
        console.log('SSE error, auto-reconnecting...');
      }
    };
  }
 
  on(type, handler) {
    this.handlers.set(type, handler);
    // For named events, add a listener
    if (type !== 'connected') {
      this.eventSource.addEventListener(type, (event) => {
        handler(JSON.parse(event.data));
      });
    }
  }
 
  close() {
    this.eventSource.close();
  }
}
 
// Usage
const sse = new SSEClient('/events');
sse.on('price-update', (data) => {
  updatePriceDisplay(data.symbol, data.price);
});
sse.on('notification', (data) => {
  showToast(data.message);
});

Long Polling Implementation

Server (Node.js)

const http = require('http');
 
const pendingResponses = new Map(); // topic -> [res, ...]
const messageHistory = new Map(); // topic -> [messages]
const MAX_HISTORY = 100;
const TIMEOUT = 55000; // 55 seconds
 
const server = http.createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost');
 
  if (url.pathname === '/poll') {
    const topic = url.searchParams.get('topic') || 'default';
    const lastId = parseInt(url.searchParams.get('lastId') || '0');
 
    // Send missed messages immediately
    const history = messageHistory.get(topic) || [];
    const missed = history.filter(m => m.id > lastId);
    if (missed.length > 0) {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ messages: missed, lastId: missed[missed.length - 1].id }));
      return;
    }
 
    // Hold connection open
    if (!pendingResponses.has(topic)) pendingResponses.set(topic, []);
    const pending = pendingResponses.get(topic);
    const entry = { res, lastId };
    pending.push(entry);
 
    // Timeout after 55 seconds
    const timer = setTimeout(() => {
      const idx = pending.indexOf(entry);
      if (idx > -1) pending.splice(idx, 1);
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ messages: [], lastId }));
    }, TIMEOUT);
 
    req.on('close', () => {
      clearTimeout(timer);
      const idx = pending.indexOf(entry);
      if (idx > -1) pending.splice(idx, 1);
    });
  }
 
  if (url.pathname === '/send' && req.method === 'POST') {
    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      const topic = url.searchParams.get('topic') || 'default';
      const message = JSON.parse(body);
 
      // Store in history
      if (!messageHistory.has(topic)) messageHistory.set(topic, []);
      const history = messageHistory.get(topic);
      const id = (history[history.length - 1]?.id || 0) + 1;
      const fullMessage = { ...message, id, timestamp: Date.now() };
      history.push(fullMessage);
      if (history.length > MAX_HISTORY) history.shift();
 
      // Wake up pending connections
      const pending = pendingResponses.get(topic) || [];
      while (pending.length > 0) {
        const { res, lastId } = pending.shift();
        const missed = history.filter(m => m.id > lastId);
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ messages: missed, lastId: id }));
      }
 
      res.writeHead(200);
      res.end('OK');
    });
  }
});
 
server.listen(8080);

Client with Error Handling

class LongPollingClient {
  constructor(url, options = {}) {
    this.url = url;
    this.active = false;
    this.handlers = new Map();
    this.lastId = 0;
    this.maxRetries = options.maxRetries ?? 5;
    this.retryCount = 0;
  }
 
  async start() {
    this.active = true;
    while (this.active) {
      try {
        const url = `${this.url}?lastId=${this.lastId}`;
        const response = await fetch(url, {
          signal: AbortSignal.timeout(60000),
        });
 
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
 
        const { messages, lastId } = await response.json();
        this.lastId = lastId || this.lastId;
        this.retryCount = 0;
 
        messages.forEach((message) => {
          const handler = this.handlers.get(message.type);
          if (handler) handler(message);
        });
      } catch (error) {
        if (!this.active) break;
        this.retryCount++;
        const delay = Math.min(1000 * Math.pow(2, this.retryCount), 30000);
        console.error(`Polling error (retry ${this.retryCount}):`, error.message);
        await new Promise(r => setTimeout(r, delay));
      }
    }
  }
 
  on(type, handler) {
    this.handlers.set(type, handler);
  }
 
  stop() {
    this.active = false;
  }
}

Performance Benchmarks

Latency Comparison

MetricWebSocketSSELong Polling
Connection setup~100ms~100ms~100ms
Message delivery (p50)<1ms~5ms~50-200ms
Message delivery (p99)~5ms~20ms~500ms
Reconnection time~1-5s~3s (auto)~1-5s
Messages per second10,000+1,000+~100

WebSockets have the lowest per-message overhead because they skip HTTP headers entirely after the initial handshake. SSE has slightly higher overhead due to the text-based event format but is still very efficient. Long polling has the highest overhead because each message delivery requires a full HTTP round-trip.

Scalability Considerations

WebSockets maintain persistent connections, which means each connected client consumes a file descriptor and a small amount of memory on the server. A single Node.js server can handle 10,000-50,000 concurrent WebSocket connections depending on message frequency and payload size. Horizontal scaling requires a pub/sub layer (Redis, NATS) to broadcast messages across server instances.

SSE connections have similar resource characteristics to WebSockets on the server side. The HTTP/2 multiplexing advantage means fewer TCP connections are needed between client and server. SSE scales the same way as WebSockets — with a pub/sub layer for multi-server deployments.

Long polling is the most resource-intensive because each message delivery creates a new HTTP request/response cycle. Servers need to handle the connection churn and maintain pending response queues. Long polling can work with standard HTTP load balancers without sticky sessions, which is an advantage in some deployments.

When to Use Each Technology

Use WebSockets When:

  • You need bidirectional communication (chat, gaming, collaborative editing)
  • You need ultra-low latency (real-time games, financial trading platforms)
  • You need to send binary data (file transfer, audio/video streaming)
  • You're building a high-frequency update system (100+ messages/second)
  • You control the server infrastructure and can handle WebSocket upgrades

Use SSE When:

  • You only need server-to-client updates (notifications, live feeds, dashboards)
  • You want automatic reconnection built into the browser API
  • You need HTTP/2 multiplexing to reduce connection overhead
  • You want simpler implementation with standard HTTP tooling
  • You need events to work through corporate proxies and CDNs

Use Long Polling When:

  • You need maximum compatibility with legacy infrastructure
  • Your network blocks WebSocket upgrades and SSE
  • Updates are infrequent (every few seconds or longer)
  • You're migrating gradually from a traditional polling architecture
  • You need to work behind restrictive firewalls that only allow standard HTTP

Hybrid Approach: Progressive Enhancement

Production systems often implement progressive enhancement — starting with the best transport and falling back gracefully:

class RealTimeClient {
  constructor(config) {
    this.config = config;
    this.transport = null;
    this.transportType = null;
  }
 
  async connect() {
    const transports = [
      { type: 'websocket', enabled: this.config.enableWebSocket, factory: () => new WebSocketClient(this.config.wsUrl) },
      { type: 'sse', enabled: this.config.enableSSE, factory: () => new SSEClient(this.config.sseUrl) },
      { type: 'longpoll', enabled: true, factory: () => new LongPollingClient(this.config.pollUrl) },
    ];
 
    for (const { type, enabled, factory } of transports) {
      if (!enabled) continue;
      try {
        this.transport = factory();
        this.transportType = type;
        console.log(`Using ${type} transport`);
        return;
      } catch (e) {
        console.log(`${type} failed:`, e.message);
      }
    }
  }
 
  on(type, handler) {
    this.transport?.on(type, handler);
  }
 
  send(data) {
    if (this.transportType === 'websocket') {
      this.transport.send(data);
    } else {
      // SSE and long polling use HTTP POST for client-to-server messages
      fetch('/api/send', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      });
    }
  }
}

Production Deployment Considerations

Load Balancing

WebSockets require sticky sessions or a shared pub/sub layer when running behind a load balancer. Nginx, HAProxy, and cloud load balancers all support WebSocket proxying, but you must configure the upgrade headers and timeout settings correctly.

# Nginx WebSocket proxy configuration
location /ws {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 86400; # 24 hours
    proxy_send_timeout 86400;
}

Monitoring and Debugging

Monitor your real-time connections with these metrics:

  • Active connections per server instance
  • Message throughput (messages/second in and out)
  • Connection duration (average, p95, p99)
  • Reconnection rate (frequent reconnections indicate problems)
  • Error rate per transport type

Use browser DevTools Network tab to inspect WebSocket frames, SSE events, and long polling requests. The WS tab shows individual messages with timestamps and payload sizes.

Common Pitfalls and Solutions

PitfallImpactSolution
No reconnection logicUsers disconnected permanentlyImplement exponential backoff
SSE on HTTP/1.1Connection limit (6 per domain)Use HTTP/2 or connection sharing
WebSocket without heartbeatZombie connections consume resourcesImplement ping/pong every 25-30s
No fallback transportBlocked by corporate firewallsUse hybrid approach with progressive enhancement
Missing CORS headersSSE fails cross-originSet Access-Control-Allow-Origin
No message orderingOut-of-order deliveryInclude sequence numbers in messages
Memory leak on reconnectEvent listeners accumulateClean up old listeners before reconnecting
No connection timeoutLong polling hangs indefinitelySet explicit timeout (55-60s)

Conclusion

The choice between WebSockets, SSE, and long polling depends on your communication pattern, latency requirements, and infrastructure constraints. WebSockets are the best choice for bidirectional, low-latency communication. SSE is the best choice for server-to-client streaming with minimal complexity. Long polling is the universal fallback that works everywhere.

Key takeaways:

  1. Start with SSE if you only need server-to-client updates — it's the simplest and most reliable option
  2. Use WebSockets for bidirectional communication, low latency, or binary data transfer
  3. Implement fallbacks — start with the best transport and degrade gracefully
  4. Always implement heartbeat and reconnection regardless of transport choice
  5. Use HTTP/2 for SSE to avoid connection limits
  6. Monitor active connections and message throughput in production
  7. Consider managed services (Pusher, Ably, Socket.io) if you don't want to manage WebSocket infrastructure yourself