WebSocket vs SSE vs Long Polling: Building Real-Time Features the Right Way

Not every real-time feature needs WebSocket. Learn when to use Server-Sent Events, Long Polling, or WebSocket with practical Angular examples, scaling strategies, and a decision framework that saves you from over-engineering.

WebSocket vs SSE vs Long Polling: Building Real-Time Features the Right Way illustration
On this page19 sections

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.

Share this article

Stuck on implementation?

Get private, 1-on-1 help with system design, performance, scaling, or any technical challenge.

Book a Session

Related Production Resources

Course

Free learning tracks

Turn this guide into a structured production engineering path.

Lab

Interactive engineering labs

Practice the same ideas through scenario-based simulators.

Reference

Production cheatsheets

Keep the operational commands and checks nearby.

Glossary

Key terms

Review the vocabulary behind the architecture.

Discussion

Questions, corrections, or production notes? Add them here so other learners can benefit.

Continue Reading

Related practical guides from the same production engineering path.

Frontend 12 min read

State Management Showdown: NgRx vs Signals vs Services in Angular

Angular has three state management approaches and choosing wrong means either over-engineering a todo app or under-engineering an enterprise dashboard. Compare NgRx, Signals, and plain services with real examples and a decision framework.

Angular NgRx
Frontend 12 min read

Web Authentication API: Passwordless Login with Passkeys

Passwords are the weakest link in security. Passkeys replace them with biometrics and device-bound credentials. Learn WebAuthn, FIDO2, and how to implement passwordless login in your web app with real code examples.

WebAuthn Passkeys