Your product manager wants “real-time updates.” Before you reach for WebSocket, stop. The right choice depends on your data flow direction, scale requirements, and infrastructure. Picking the wrong protocol means either over-engineering a simple notification feed or under-engineering a chat system that collapses at scale.

This guide breaks down the three main approaches with real code, honest tradeoffs, and a decision framework you can actually use.

Why HTTP Request-Response Falls Short

Standard HTTP is a pull model — the client asks, the server responds. For real-time features, this means the client must keep asking “anything new?” repeatedly.

// Naive polling: wasteful and laggy
setInterval(async () => {
  const res = await fetch('/api/notifications');
  const data = await res.json();
  updateUI(data);
}, 5000); // 5-second delay + wasted requests when nothing changed

This approach wastes bandwidth (most responses return “nothing new”), introduces latency (up to 5 seconds before you see an update), and hammers your server with unnecessary requests.

Long Polling: The Simplest Upgrade

Long polling flips the script: the client sends a request, but the server holds it open until there is new data to send. Once the client receives a response, it immediately sends another request.

How It Works

// Client-side long polling
async function longPoll() {
  try {
    const res = await fetch('/api/events?since=lastEventId', {
      signal: AbortSignal.timeout(30000) // 30s timeout
    });
    const data = await res.json();
    handleNewData(data);
  } catch (err) {
    // Timeout or error: wait briefly, then retry
    await new Promise(r => setTimeout(r, 1000));
  }
  longPoll(); // Immediately reconnect
}
// Server-side (Node.js/Express)
app.get('/api/events', async (req, res) => {
  const since = req.query.since;

  // Check for new data, wait up to 25 seconds
  const data = await waitForNewEvents(since, 25000);

  if (data) {
    res.json(data);
  } else {
    res.status(204).end(); // No new data, client will retry
  }
});

When Long Polling Makes Sense

  • You need maximum browser and proxy compatibility
  • Updates are infrequent (less than once per second)
  • You cannot use WebSocket due to corporate proxy restrictions
  • The implementation must be dead simple

Drawbacks

  • Each response requires a new HTTP connection (overhead)
  • Slight latency gap between response and next request
  • Server must hold many open connections simultaneously
  • Not suitable for high-frequency updates

Server-Sent Events (SSE): The Underrated Middle Ground

SSE provides a persistent, one-way channel from server to client over standard HTTP. The browser natively supports it via the EventSource API, with automatic reconnection built in.

Server Implementation

// Node.js/Express SSE endpoint
app.get('/api/stream', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });

  // Send a comment every 15s to keep the connection alive
  const heartbeat = setInterval(() => {
    res.write(': heartbeat\n\n');
  }, 15000);

  // Send events as they happen
  const onNewOrder = (order) => {
    res.write(`event: new-order\n`);
    res.write(`data: ${JSON.stringify(order)}\n\n`);
  };

  eventEmitter.on('order:created', onNewOrder);

  req.on('close', () => {
    clearInterval(heartbeat);
    eventEmitter.off('order:created', onNewOrder);
  });
});

Angular Client with Signals

// notification.service.ts
import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class NotificationService {
  notifications = signal<Notification[]>([]);
  connected = signal(false);
  private eventSource: EventSource | null = null;

  connect() {
    this.eventSource = new EventSource('/api/stream');
    this.connected.set(true);

    this.eventSource.addEventListener('new-order', (event) => {
      const order = JSON.parse(event.data);
      this.notifications.update(list => [order, ...list]);
    });

    this.eventSource.onerror = () => {
      this.connected.set(false);
      // EventSource automatically reconnects!
    };

    this.eventSource.onopen = () => {
      this.connected.set(true);
    };
  }

  disconnect() {
    this.eventSource?.close();
    this.connected.set(false);
  }
}

SSE Advantages

  • Automatic reconnection with configurable retry interval (built into EventSource)
  • Event ID tracking — resume from where you left off via Last-Event-ID header
  • Works over HTTP/2 — multiplexed connections, no head-of-line blocking
  • Firewall and proxy friendly — standard HTTP, no upgrade needed
  • Native browser API — no library required

SSE Limitations

  • Unidirectional — server to client only (client uses regular HTTP for sending)
  • Text only — no binary data (must base64 encode)
  • Maximum 6 connections per domain in HTTP/1.1 (solved by HTTP/2)

WebSocket: Full-Duplex Power

WebSocket provides a persistent, bidirectional channel. After an HTTP handshake upgrade, both client and server can send messages at any time without request-response overhead.

Server Implementation

// Node.js WebSocket server (using ws library)
import { WebSocketServer } from 'ws';

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

wss.on('connection', (ws) => {
  clients.add(ws);

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

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

    // Broadcast to all other clients
    clients.forEach(client => {
      if (client !== ws && client.readyState === 1) {
        client.send(JSON.stringify(message));
      }
    });
  });

  ws.on('close', () => { clients.delete(ws); });
});

// Ping every 30 seconds to detect stale connections
setInterval(() => {
  wss.clients.forEach(ws => {
    if (!ws.isAlive) return ws.terminate();
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

Angular Chat Component

// chat.service.ts
import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class ChatService {
  messages = signal<ChatMessage[]>([]);
  connectionState = signal<'connecting' | 'open' | 'closed'>('closed');
  private ws: WebSocket | null = null;
  private reconnectAttempts = 0;

  connect(roomId: string) {
    this.connectionState.set('connecting');
    this.ws = new WebSocket(`wss://api.example.com/chat/${roomId}`);

    this.ws.onopen = () => {
      this.connectionState.set('open');
      this.reconnectAttempts = 0;
    };

    this.ws.onmessage = (event) => {
      const msg = JSON.parse(event.data);
      this.messages.update(list => [...list, msg]);
    };

    this.ws.onclose = () => {
      this.connectionState.set('closed');
      this.reconnect(roomId);
    };
  }

  send(content: string) {
    this.ws?.send(JSON.stringify({ content, timestamp: Date.now() }));
  }

  private reconnect(roomId: string) {
    const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
    this.reconnectAttempts++;
    setTimeout(() => this.connect(roomId), delay);
  }
}

Head-to-Head Comparison

Feature Long Polling SSE WebSocket
Direction Server → Client Server → Client Bidirectional
Protocol HTTP HTTP WS (upgrade from HTTP)
Auto Reconnect Manual Built-in Manual
Binary Support Yes No (text only) Yes
HTTP/2 Compatible Yes Yes (multiplexed) No (separate connection)
Proxy Friendly Very Yes Sometimes problematic
Latency Medium Low Lowest
Connections per Server ~10K ~50K ~50K
Memory per Connection ~10KB ~5KB ~8KB
Complexity Low Low Medium-High

Decision Framework

Use this flowchart to pick the right approach:

  • Does the client need to send frequent messages? → WebSocket (chat, gaming, collaborative editing)
  • Is it server-to-client only? → SSE (notifications, live feeds, dashboards, stock tickers)
  • Do you need maximum compatibility with zero dependencies? → Long Polling (legacy systems, restrictive proxies)
  • Is it a live sports score or news feed? → SSE (one-way, auto-reconnect, event types)
  • Is it a multiplayer game or real-time collaboration? → WebSocket (bidirectional, low latency, binary frames)

Scaling Considerations

All three approaches face the same fundamental challenge at scale: sticky sessions. When a client connects to Server A, that server holds the connection state. If an event originates on Server B, it must reach Server A to deliver to the client.

Solution: Redis Pub/Sub

// Every server subscribes to a Redis channel
import Redis from 'ioredis';

const redisSub = new Redis();
const redisPub = new Redis();

// When a new event happens on any server:
redisPub.publish('notifications', JSON.stringify(event));

// Every server listens and forwards to its connected clients:
redisSub.subscribe('notifications');
redisSub.on('message', (channel, data) => {
  const event = JSON.parse(data);
  connectedClients.forEach(client => client.send(data));
});

Common Mistakes

  • No heartbeat mechanism: Connections silently die behind NATs and proxies. Always implement ping/pong (WebSocket) or comment-based heartbeats (SSE).
  • Memory leaks from unclosed connections: Always clean up event listeners and remove clients from tracking sets on disconnect.
  • Missing reconnection with backoff: Network blips happen. Implement exponential backoff (1s, 2s, 4s, 8s) with a maximum delay cap.
  • Using WebSocket when SSE is enough: If your data only flows server-to-client, SSE is simpler, more reliable, and works better with HTTP/2.
  • Not handling message ordering: Messages can arrive out of order after reconnection. Include sequence numbers or timestamps.

Key Takeaways

  • SSE is the right default for most real-time features — notifications, feeds, dashboards
  • WebSocket is for bidirectional needs — chat, gaming, collaboration
  • Long Polling still has its place — maximum compatibility, simple infrastructure
  • Always implement heartbeats and reconnection regardless of which approach you choose
  • Use Redis Pub/Sub for horizontal scaling to distribute events across server instances
  • Start with SSE, upgrade to WebSocket only when you hit its limitations — premature WebSocket is a common source of unnecessary complexity

The best real-time architecture is the simplest one that meets your requirements. Do not let the appeal of WebSocket trick you into over-engineering a notification bell.