Published on

WebSockets vs SSE vs Long Polling — Choosing Real-Time Communication in 2026

Authors

Introduction

Real-time communication powers modern apps: live notifications, collaborative editing, stock tickers, chat. But real-time comes in flavors, each with tradeoffs.

WebSockets offer full-duplex, low-latency communication but require sticky sessions. Server-Sent Events (SSE) are unidirectional from server to client but work through standard HTTP. Long polling is inefficient but works everywhere.

This guide builds production implementations of all three, so you can choose wisely.

WebSocket Full-Duplex Communication

WebSockets establish persistent TCP connections, enabling true two-way communication.

// lib/websocket.ts
import WebSocket from 'ws';
import { createServer } from 'http';

interface ClientMessage {
  type: 'subscribe' | 'publish' | 'ping';
  channel: string;
  data?: any;
}

interface ServerMessage {
  type: 'message' | 'pong' | 'error';
  channel: string;
  data?: any;
  timestamp: number;
}

class WebSocketManager {
  private wss: WebSocket.Server;
  private channels = new Map<string, Set<WebSocket>>();
  private clientIds = new WeakMap<WebSocket, string>();

  constructor(server: any) {
    this.wss = new WebSocket.Server({ server });

    this.wss.on('connection', (ws: WebSocket) => {
      console.log('WebSocket connected');

      // Heartbeat to detect dead connections
      let isAlive = true;
      ws.on('pong', () => {
        isAlive = true;
      });

      const interval = setInterval(() => {
        if (!isAlive) {
          return ws.terminate();
        }
        isAlive = false;
        ws.ping();
      }, 30000);

      ws.on('message', (data: Buffer) => {
        try {
          this.handleMessage(ws, JSON.parse(data.toString()) as ClientMessage);
        } catch (err) {
          console.error('WebSocket message error:', err);
          ws.send(
            JSON.stringify({
              type: 'error',
              data: 'Invalid message format',
            })
          );
        }
      });

      ws.on('close', () => {
        clearInterval(interval);
        this.handleDisconnect(ws);
      });

      ws.on('error', (err) => {
        console.error('WebSocket error:', err);
      });
    });
  }

  private handleMessage(ws: WebSocket, message: ClientMessage) {
    switch (message.type) {
      case 'subscribe':
        this.subscribe(ws, message.channel);
        break;
      case 'publish':
        this.publish(message.channel, {
          type: 'message',
          channel: message.channel,
          data: message.data,
          timestamp: Date.now(),
        });
        break;
      case 'ping':
        ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
        break;
    }
  }

  private subscribe(ws: WebSocket, channel: string) {
    if (!this.channels.has(channel)) {
      this.channels.set(channel, new Set());
    }

    this.channels.get(channel)!.add(ws);
    console.log(`Client subscribed to ${channel}`);
  }

  private publish(channel: string, message: ServerMessage) {
    const subscribers = this.channels.get(channel);
    if (!subscribers) return;

    subscribers.forEach((ws) => {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify(message));
      }
    });
  }

  private handleDisconnect(ws: WebSocket) {
    // Remove from all channels
    for (const subscribers of this.channels.values()) {
      subscribers.delete(ws);
    }
  }

  public broadcast(channel: string, data: any) {
    this.publish(channel, {
      type: 'message',
      channel,
      data,
      timestamp: Date.now(),
    });
  }
}

// Express integration
import express from 'express';

const app = express();
const server = createServer(app);
const wsManager = new WebSocketManager(server);

app.post('/api/broadcast/:channel', (req, res) => {
  wsManager.broadcast(req.params.channel, req.body);
  res.json({ sent: true });
});

server.listen(3000);

Client-side WebSocket:

// Client WebSocket connection
class WebSocketClient {
  private ws: WebSocket | null = null;
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 5;
  private reconnectDelay = 1000;
  private messageHandlers = new Map<string, Set<Function>>();

  connect(url: string) {
    this.ws = new WebSocket(url);

    this.ws.onopen = () => {
      console.log('WebSocket connected');
      this.reconnectAttempts = 0;
    };

    this.ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      this.handleMessage(message);
    };

    this.ws.onerror = (error) => {
      console.error('WebSocket error:', error);
    };

    this.ws.onclose = () => {
      this.attemptReconnect(url);
    };
  }

  private attemptReconnect(url: string) {
    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      this.reconnectAttempts++;
      const delay = Math.min(
        this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
        30000
      );
      console.log(`Reconnecting in ${delay}ms...`);
      setTimeout(() => this.connect(url), delay);
    }
  }

  subscribe(channel: string, handler: (data: any) => void) {
    if (!this.messageHandlers.has(channel)) {
      this.messageHandlers.set(channel, new Set());

      // Send subscribe message
      this.send({
        type: 'subscribe',
        channel,
      });
    }

    this.messageHandlers.get(channel)!.add(handler);
  }

  private handleMessage(message: any) {
    if (message.type === 'message') {
      const handlers = this.messageHandlers.get(message.channel);
      handlers?.forEach((h) => h(message.data));
    }
  }

  send(message: any) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(message));
    }
  }

  disconnect() {
    this.ws?.close();
  }
}

SSE Unidirectional Communication

Server-Sent Events stream data to clients over HTTP. Simpler than WebSockets but unidirectional.

// lib/sse.ts
import { Response } from 'express';

class SSEManager {
  private clients = new Set<{
    res: Response;
    channel: string;
    id: string;
  }>();

  subscribe(res: Response, channel: string) {
    // 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', '*');

    const clientId = Math.random().toString(36);

    const client = { res, channel, id: clientId };
    this.clients.add(client);

    // Send initial connection message
    res.write('data: {"type":"connected"}\n\n');

    // Heartbeat to keep connection alive
    const interval = setInterval(() => {
      res.write(': heartbeat\n\n');
    }, 30000);

    // Clean up on disconnect
    res.on('close', () => {
      clearInterval(interval);
      this.clients.delete(client);
      console.log(`SSE client ${clientId} disconnected`);
    });

    res.on('error', (err) => {
      console.error('SSE error:', err);
      this.clients.delete(client);
    });

    return clientId;
  }

  publish(channel: string, data: any) {
    for (const client of this.clients) {
      if (client.channel === channel && client.res.writable) {
        // SSE format: event, id, data (all optional)
        client.res.write(`id: ${Date.now()}\n`);
        client.res.write(`data: ${JSON.stringify(data)}\n\n`);
      }
    }
  }

  broadcast(data: any) {
    for (const client of this.clients) {
      if (client.res.writable) {
        client.res.write(`id: ${Date.now()}\n`);
        client.res.write(`data: ${JSON.stringify(data)}\n\n`);
      }
    }
  }
}

// Express routes
const sseManager = new SSEManager();

app.get('/api/sse/subscribe/:channel', (req, res) => {
  sseManager.subscribe(res, req.params.channel);
});

app.post('/api/sse/publish/:channel', (req, res) => {
  sseManager.publish(req.params.channel, req.body);
  res.json({ published: true });
});

Client-side SSE:

// Client SSE connection
class SSEClient {
  private eventSource: EventSource | null = null;

  subscribe(channel: string, handler: (data: any) => void) {
    this.eventSource = new EventSource(`/api/sse/subscribe/${channel}`);

    this.eventSource.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        handler(data);
      } catch (err) {
        console.error('Failed to parse SSE message:', err);
      }
    };

    this.eventSource.onerror = () => {
      console.log('SSE connection lost, EventSource will auto-reconnect');
    };
  }

  disconnect() {
    this.eventSource?.close();
  }
}

// Auto-reconnect handled by browser; custom backoff:
class SSEClientWithBackoff {
  private eventSource: EventSource | null = null;
  private maxRetries = 5;
  private retries = 0;
  private baseDelay = 1000;

  subscribe(channel: string, handler: (data: any) => void) {
    this.eventSource = new EventSource(`/api/sse/subscribe/${channel}`);

    this.eventSource.onmessage = (event) => {
      this.retries = 0; // Reset on successful message
      handler(JSON.parse(event.data));
    };

    this.eventSource.onerror = () => {
      this.eventSource?.close();

      if (this.retries < this.maxRetries) {
        const delay = this.baseDelay * Math.pow(2, this.retries);
        this.retries++;
        console.log(`Reconnecting in ${delay}ms...`);
        setTimeout(() => this.subscribe(channel, handler), delay);
      }
    };
  }

  disconnect() {
    this.eventSource?.close();
  }
}

Long Polling Fallback

Long polling works everywhere but is bandwidth-inefficient.

// lib/longPolling.ts
class LongPollingManager {
  private messageQueues = new Map<string, any[]>();
  private clientWaiters = new Map<string, Array<(msgs: any[]) => void>>();

  subscribe(channel: string, timeout = 30000): Promise<any[]> {
    return new Promise((resolve) => {
      // Check for pending messages
      const pending = this.messageQueues.get(channel);
      if (pending && pending.length > 0) {
        const messages = pending.splice(0, pending.length);
        return resolve(messages);
      }

      // Register waiter
      if (!this.clientWaiters.has(channel)) {
        this.clientWaiters.set(channel, []);
      }

      const waiters = this.clientWaiters.get(channel)!;
      const timeoutId = setTimeout(() => {
        waiters.splice(waiters.indexOf(resolve), 1);
        resolve([]); // Empty array = no new messages
      }, timeout);

      const wrappedResolve = (msgs: any[]) => {
        clearTimeout(timeoutId);
        waiters.splice(waiters.indexOf(wrappedResolve), 1);
        resolve(msgs);
      };

      waiters.push(wrappedResolve);
    });
  }

  publish(channel: string, data: any) {
    const waiters = this.clientWaiters.get(channel);

    if (waiters && waiters.length > 0) {
      // Active waiters: resolve immediately
      const waiter = waiters.shift();
      waiter?.([data]);
    } else {
      // No waiters: queue message
      if (!this.messageQueues.has(channel)) {
        this.messageQueues.set(channel, []);
      }
      this.messageQueues.get(channel)!.push(data);
    }
  }
}

// Express routes
const pollManager = new LongPollingManager();

app.post('/api/poll/:channel', async (req, res) => {
  const messages = await pollManager.subscribe(req.params.channel, 30000);
  res.json({ messages });
});

app.post('/api/publish/:channel', (req, res) => {
  pollManager.publish(req.params.channel, req.body);
  res.json({ published: true });
});

Client-side long polling:

// Client long polling
class PollingClient {
  private polling = true;

  async subscribe(channel: string, handler: (data: any) => void) {
    while (this.polling) {
      try {
        const response = await fetch(`/api/poll/${channel}`, {
          method: 'POST',
          timeout: 35000, // Slightly more than server timeout
        });

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

        for (const message of messages) {
          handler(message);
        }

        // Immediate re-poll if messages received
        // Otherwise, exponential backoff
        if (messages.length === 0) {
          await new Promise((resolve) => setTimeout(resolve, 100));
        }
      } catch (err) {
        console.error('Polling error:', err);
        await new Promise((resolve) => setTimeout(resolve, 1000));
      }
    }
  }

  stop() {
    this.polling = false;
  }
}

WebSocket Load Balancer Sticky Sessions

WebSocket connections are stateful. Load balancers must stick connections to same server.

# Nginx: sticky sessions for WebSocket
upstream websocket_backend {
  # Hash by remote IP for sticky routing
  hash $remote_addr consistent;

  server 10.0.1.1:3000;
  server 10.0.1.2:3000;
  server 10.0.1.3:3000;
}

server {
  listen 80;

  location /ws {
    proxy_pass http://websocket_backend;

    # WebSocket upgrade headers
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    # Timeouts
    proxy_read_timeout 86400;
    proxy_send_timeout 86400;
  }
}

Kubernetes sticky sessions:

# k8s-service.yaml for WebSocket
apiVersion: v1
kind: Service
metadata:
  name: websocket-service
spec:
  type: LoadBalancer
  sessionAffinity: ClientIP # Sticky sessions by client IP
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 10800 # 3 hours

  selector:
    app: websocket-server

  ports:
    - protocol: TCP
      port: 80
      targetPort: 3000

Redis pub/sub for cross-server WebSocket communication:

// Broadcast across multiple servers via Redis
import redis from 'redis';

const pubClient = redis.createClient(process.env.REDIS_URL);
const subClient = redis.createClient(process.env.REDIS_URL);

class DistributedWebSocketManager extends WebSocketManager {
  constructor(server: any) {
    super(server);

    // Subscribe to all channels
    subClient.subscribe('*', (err, count) => {
      if (err) console.error('Failed to subscribe:', err);
      else console.log(`Subscribed to ${count} channels`);
    });

    subClient.on('message', (channel, message) => {
      // Publish to local WebSocket clients
      this.broadcast(channel, JSON.parse(message));
    });
  }

  public broadcast(channel: string, data: any) {
    super.broadcast(channel, data);

    // Also publish to Redis for other servers
    pubClient.publish(channel, JSON.stringify(data));
  }
}

Comparison Table

FeatureWebSocketSSELong Polling
Latency~50ms~100ms~1-5s
BandwidthLowLowHigh
Client → ServerNativeNo (HTTP POST)Native
Server → ClientNativeNativeNative
Browser SupportModern browsersModern browsersAll browsers
Sticky SessionsRequiredOptionalNot needed
Firewall FriendlySome proxy issuesWorks through HTTPWorks through HTTP
ComplexityMediumLowLow
Auto-reconnectManualBuilt-inManual
Connection Overhead2KB handshakeHTTP headersHTTP request/response

When Polling Is Actually Fine

Don't over-engineer:

// Perfect use cases for polling:
// 1. Low-frequency updates (every 30+ seconds)
// 2. Limited concurrent users (<1000)
// 3. Best-effort delivery (chat history available)
// 4. Simple implementation priority

// Example: notification bell checking
setInterval(async () => {
  const response = await fetch('/api/notifications?since=' + lastCheck);
  const { notifications } = await response.json();

  notifications.forEach((n) => showNotification(n));
  lastCheck = Date.now();
}, 60000); // Poll every minute

Conclusion

Choose based on requirements:

  • WebSockets: Real-time, low-latency, bi-directional (chat, collaborative tools)
  • SSE: Real-time, server→client, unidirectional (notifications, feeds)
  • Polling: Best-effort, simple, works everywhere (non-critical updates)

Use Redis pub/sub to scale WebSockets across multiple servers. Use sticky sessions on load balancers. Implement auto-reconnect logic in clients.

Build real-time without the real-time complexity tax.