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

WebSocket vs Server-Sent Events: Real-Time Compared

Compare WebSocket and SSE for real-time communication: protocol differences and use cases.

WebSocketSSEReal-TimeBackend

By MinhVo

Introduction

Choosing between WebSocket and Server-Sent Events (SSE) is one of the most consequential architectural decisions you'll make for real-time web applications. Both technologies enable server-to-client push communication, but they differ fundamentally in their protocol design, capabilities, complexity, and operational characteristics. WebSocket provides full-duplex, bidirectional communication over a single TCP connection, while SSE offers a simpler, HTTP-based unidirectional stream from server to client.

Hero image

The choice between these two technologies is not about which is "better" in absolute terms — it's about which is the right tool for your specific requirements. A chat application that needs low-latency bidirectional messaging is fundamentally different from a stock ticker that streams price updates from server to client. Understanding the trade-offs between WebSocket and SSE will help you make informed decisions that affect your application's reliability, scalability, and developer experience for years to come.

This guide provides a comprehensive, honest comparison of both technologies across every dimension that matters for production systems: protocol overhead, browser support, scalability, reconnection behavior, security, and real-world use cases with code examples.

Understanding WebSocket and SSE: Core Concepts

How WebSocket Works

WebSocket establishes a persistent, full-duplex connection through an HTTP upgrade handshake. After the upgrade, both client and server can send messages at any time with minimal framing overhead (2–14 bytes per frame).

// Client-side WebSocket
const ws = new WebSocket('wss://api.example.com/socket');
 
ws.onopen = () => {
  console.log('Connection established');
  ws.send(JSON.stringify({ type: 'subscribe', channel: 'prices' }));
};
 
ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  updateUI(data);
};
 
ws.onclose = (event) => {
  console.log(`Closed: code=${event.code}, reason=${event.reason}`);
};
 
ws.onerror = (error) => {
  console.error('WebSocket error:', error);
};
// Server-side (Node.js with ws library)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
 
wss.on('connection', (ws, req) => {
  const ip = req.socket.remoteAddress;
  console.log(`Client connected from ${ip}`);
 
  ws.on('message', (message) => {
    const data = JSON.parse(message);
    handleClientMessage(ws, data);
  });
 
  ws.on('close', () => {
    console.log('Client disconnected');
  });
});

How Server-Sent Events Work

SSE uses a standard HTTP connection with a text/event-stream content type. The server sends events as newline-delimited text, and the browser's EventSource API handles reconnection automatically.

// Client-side SSE
const eventSource = new EventSource('/api/events');
 
eventSource.onopen = () => {
  console.log('SSE connection established');
};
 
eventSource.addEventListener('price-update', (event) => {
  const data = JSON.parse(event.data);
  updatePriceDisplay(data);
});
 
eventSource.addEventListener('notification', (event) => {
  const data = JSON.parse(event.data);
  showNotification(data);
});
 
eventSource.onerror = (event) => {
  if (eventSource.readyState === EventSource.CONNECTING) {
    console.log('Reconnecting...');
  }
};
// Server-side SSE (Express)
app.get('/api/events', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'Access-Control-Allow-Origin': '*',
  });
 
  // Send initial event with last event ID
  res.write('retry: 3000\n\n');
 
  const sendEvent = (eventName, data, id) => {
    if (id) res.write(`id: ${id}\n`);
    res.write(`event: ${eventName}\n`);
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  };
 
  // Push events
  const interval = setInterval(() => {
    sendEvent('heartbeat', { timestamp: Date.now() }, null);
  }, 15000);
 
  // Subscribe to data source
  const unsubscribe = priceStream.subscribe((price) => {
    sendEvent('price-update', price, price.id);
  });
 
  req.on('close', () => {
    clearInterval(interval);
    unsubscribe();
  });
});

SSE Event Format

The SSE protocol defines a simple text-based format with four field types:

event: message-type
data: {"key": "value"}
id: 12345
retry: 5000

Each field is a line starting with the field name, a colon, and the value. Events are separated by blank lines. The data field can span multiple lines (each prefixed with data:), and they're concatenated with newlines.

Concept illustration

Architecture and Design Patterns

Protocol Comparison

AspectWebSocketSSE
DirectionFull-duplex (bidirectional)Server → Client (unidirectional)
ProtocolCustom binary framing over TCPHTTP/1.1 text streaming
Connection setupHTTP upgrade (101)Standard HTTP GET
Message formatText or binary framesText-only (UTF-8)
Header overhead2–14 bytes per frameHTTP headers on initial request only
ReconnectionManual implementationBuilt-in (EventSource API)
Last-event trackingManual implementationBuilt-in (Last-Event-ID header)
HTTP/2 multiplexingNot applicable (separate connection)Multiplexed over single connection
Proxy compatibilityGood (after upgrade)Excellent (standard HTTP)

Reconnection Pattern Comparison

// WebSocket: Manual reconnection
class WebSocketClient {
  constructor(url) {
    this.url = url;
    this.reconnectDelay = 1000;
    this.maxDelay = 30000;
    this.connect();
  }
 
  connect() {
    this.ws = new WebSocket(this.url);
 
    this.ws.onopen = () => {
      this.reconnectDelay = 1000; // Reset on successful connection
      this.onConnect?.();
    };
 
    this.ws.onclose = (event) => {
      if (event.code !== 1000) { // Not a clean close
        this.scheduleReconnect();
      }
    };
 
    this.ws.onerror = () => {
      this.ws.close(); // Will trigger onclose and reconnection
    };
  }
 
  scheduleReconnect() {
    setTimeout(() => {
      this.reconnectDelay = Math.min(
        this.reconnectDelay * 2,
        this.maxDelay
      );
      this.connect();
    }, this.reconnectDelay);
  }
}
 
// SSE: Automatic reconnection with Last-Event-ID
const eventSource = new EventSource('/api/events');
// Browser automatically reconnects with Last-Event-ID header
// Server can resume from the last sent event

Scalability Patterns

// WebSocket: Connection-based scaling with Redis pub/sub
const Redis = require('ioredis');
const redis = new Redis();
const subscriber = new Redis();
 
// Each server instance subscribes to Redis channels
subscriber.subscribe('broadcast');
subscriber.on('message', (channel, message) => {
  wss.clients.forEach(client => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(message);
    }
  });
});
 
// SSE: Simpler scaling with Redis pub/sub
app.get('/api/events', (req, res) => {
  setupSSEHeaders(res);
  
  const subscriber = new Redis();
  subscriber.subscribe('broadcast');
  
  subscriber.on('message', (channel, message) => {
    res.write(`data: ${message}\n\n`);
  });
  
  req.on('close', () => {
    subscriber.unsubscribe();
    subscriber.quit();
  });
});

Step-by-Step Implementation

Building a Real-Time Dashboard with Both Approaches

Let's implement the same feature — a real-time metrics dashboard — using both WebSocket and SSE to compare the implementations.

WebSocket Approach:

// Server (ws library)
const WebSocket = require('ws');
const { EventEmitter } = require('events');
 
class MetricsServer {
  constructor(port) {
    this.wss = new WebSocket.Server({ port });
    this.metrics = new EventEmitter();
    
    this.wss.on('connection', (ws, req) => {
      // Parse subscribed channels from URL
      const url = new URL(req.url, 'http://localhost');
      const channels = url.searchParams.get('channels')?.split(',') || [];
      
      ws.subscribedChannels = new Set(channels);
      
      ws.on('message', (raw) => {
        const msg = JSON.parse(raw);
        if (msg.type === 'subscribe') {
          ws.subscribedChannels.add(msg.channel);
        } else if (msg.type === 'unsubscribe') {
          ws.subscribedChannels.delete(msg.channel);
        }
      });
      
      // Send current metrics
      ws.send(JSON.stringify({
        type: 'snapshot',
        data: this.getCurrentMetrics()
      }));
    });
    
    // Push updates
    this.metrics.on('update', (channel, data) => {
      this.wss.clients.forEach(client => {
        if (client.readyState === WebSocket.OPEN &&
            client.subscribedChannels.has(channel)) {
          client.send(JSON.stringify({
            type: 'update',
            channel,
            data,
            timestamp: Date.now()
          }));
        }
      });
    });
  }
}
// Client
const ws = new WebSocket('wss://api.example.com/metrics?channels=cpu,memory,disk');
 
ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  switch (msg.type) {
    case 'snapshot':
      initializeDashboard(msg.data);
      break;
    case 'update':
      updateMetric(msg.channel, msg.data);
      break;
  }
};
 
// Dynamic subscription
function subscribeToChannel(channel) {
  ws.send(JSON.stringify({ type: 'subscribe', channel }));
}

SSE Approach:

// Server (Express)
app.get('/api/metrics', (req, res) => {
  setupSSEHeaders(res);
  
  const channels = req.query.channels?.split(',') || [];
  const lastEventId = req.headers['last-event-id'];
  
  // Send missed events if reconnecting
  if (lastEventId) {
    const missedEvents = getEventsSince(lastEventId);
    missedEvents.forEach(event => {
      sendSSE(res, event.event, event.data, event.id);
    });
  }
  
  // Subscribe to channels
  const unsubscribe = metricsStream.subscribe(channels, (channel, data, id) => {
    sendSSE(res, 'update', { channel, data }, id);
  });
  
  req.on('close', unsubscribe);
});
 
function setupSSEHeaders(res) {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no', // Disable Nginx buffering
  });
}
 
function sendSSE(res, event, data, id) {
  if (id) res.write(`id: ${id}\n`);
  res.write(`event: ${event}\n`);
  res.write(`data: ${JSON.stringify(data)}\n\n`);
}
// Client
const eventSource = new EventSource('/api/metrics?channels=cpu,memory,disk');
 
eventSource.addEventListener('update', (event) => {
  const { channel, data } = JSON.parse(event.data);
  updateMetric(channel, data);
});
 
// Reconnection is automatic — no code needed
// Server receives Last-Event-ID header and sends missed events

Bidirectional Communication with SSE

When you need bidirectional communication but prefer SSE's simplicity, use SSE for server→client and regular HTTP POST for client→server:

// Client: SSE for receiving, POST for sending
const eventSource = new EventSource('/api/chat');
const roomId = 'general';
 
eventSource.addEventListener('message', (event) => {
  const msg = JSON.parse(event.data);
  appendMessage(msg);
});
 
async function sendMessage(text) {
  await fetch('/api/chat/messages', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ roomId, text })
  });
}
// Server
app.get('/api/chat', (req, res) => {
  setupSSEHeaders(res);
  const unsubscribe = chatService.subscribeToRoom(roomId, (msg) => {
    sendSSE(res, 'message', msg, msg.id);
  });
  req.on('close', unsubscribe);
});
 
app.post('/api/chat/messages', (req, res) => {
  const message = chatService.addMessage(req.body);
  res.json(message);
});

Implementation workflow

Real-World Use Cases and Case Studies

Use Case 1: Live Sports Scoreboard

A sports streaming platform chose SSE for its live scoreboard because all data flows server-to-client (score updates, play-by-play, injury reports). SSE's automatic reconnection and Last-Event-ID support meant users never missed a play, even on flaky mobile connections. The simpler server implementation reduced operational complexity, and HTTP/2 multiplexing allowed the scoreboard stream to share a connection with other API requests.

Use Case 2: Collaborative Code Editor

A collaborative code editor chose WebSocket because it requires true bidirectional, low-latency communication. Each keystroke needs to be sent to the server and broadcast to other participants with minimal delay. The 2–14 byte frame overhead of WebSocket versus SSE's HTTP header overhead per reconnection made a measurable difference at 60+ messages per second per user.

Use Case 3: Financial Trading Dashboard

A cryptocurrency trading platform uses both technologies: WebSocket for the order book and trade execution (bidirectional, sub-millisecond latency required) and SSE for price tickers and news feeds (unidirectional, automatic reconnection critical for reliability). This hybrid approach uses each technology where it excels.

Use Case 4: IoT Device Monitoring

An IoT platform streams sensor data from thousands of devices. Device-to-server communication uses HTTP POST for simplicity, while the dashboard receives updates via SSE. The SSE connection handles reconnection automatically, and the HTTP/2 multiplexing support means the dashboard can maintain multiple SSE streams (one per device group) over a single TCP connection.

Best Practices for Production

  1. Default to SSE for server-push: If you only need server-to-client communication, start with SSE. It's simpler to implement, scales more easily with existing HTTP infrastructure, and handles reconnection automatically.

  2. Use WebSocket for true bidirectional needs: If the client needs to send frequent messages at low latency (chat, gaming, collaboration), WebSocket is the right choice.

  3. Implement heartbeat for both: SSE connections can be silently dropped by proxies. Send a heartbeat event every 15–30 seconds. For WebSocket, use ping/pong frames.

  4. Set proper cache headers for SSE: Use Cache-Control: no-cache and X-Accel-Buffering: no to prevent proxies and CDNs from buffering SSE events.

  5. Use binary protocols for WebSocket: If performance matters, serialize messages with MessagePack or Protocol Buffers instead of JSON. The binary frame type avoids UTF-8 validation overhead.

  6. Implement Last-Event-ID for SSE: Track event IDs server-side and handle the Last-Event-ID header on reconnection to replay missed events.

  7. Plan for connection limits: Browsers limit concurrent connections per domain (typically 6). SSE streams count against this limit. Use HTTP/2 to multiplex, or use a subdomain for SSE connections.

  8. Monitor connection counts: Both WebSocket and SSE maintain persistent connections. Monitor server-side connection counts and implement graceful degradation when limits are reached.

Common Pitfalls and Solutions

PitfallImpactSolution
No heartbeat on SSESilent connection drops by proxiesSend a heartbeat event every 15 seconds
Buffering SSE eventsDelayed event deliverySet X-Accel-Buffering: no and Cache-Control: no-cache
No reconnection logic for WebSocketUsers disconnected permanentlyImplement exponential backoff reconnection
Using WebSocket for server-only pushUnnecessary complexityUse SSE for unidirectional server-to-client streams
Not handling EventSource connection limitBrowser blocks new connectionsUse a single SSE endpoint with multiplexed channels
Sending large payloads via WebSocketFrame fragmentation overheadKeep messages small; batch large data server-side

Performance Optimization

Connection Overhead Comparison

// Measure connection establishment time
async function benchmarkConnection() {
  // WebSocket
  const wsStart = performance.now();
  const ws = new WebSocket('wss://api.example.com/socket');
  await new Promise(resolve => ws.onopen = resolve);
  const wsTime = performance.now() - wsStart;
 
  // SSE
  const sseStart = performance.now();
  const sse = new EventSource('/api/events');
  await new Promise(resolve => sse.onopen = resolve);
  const sseTime = performance.now() - sseStart;
 
  console.log(`WebSocket: ${wsTime.toFixed(2)}ms`);
  console.log(`SSE: ${sseTime.toFixed(2)}ms`);
}

Message Throughput Optimization

// WebSocket: Batch small messages
class MessageBatcher {
  constructor(ws, flushInterval = 50) {
    this.ws = ws;
    this.buffer = [];
    this.timer = setInterval(() => this.flush(), flushInterval);
  }
 
  send(message) {
    this.buffer.push(message);
    if (this.buffer.length >= 100) this.flush();
  }
 
  flush() {
    if (this.buffer.length === 0) return;
    this.ws.send(JSON.stringify(this.buffer));
    this.buffer = [];
  }
}

Comparison with Alternatives

FeatureWebSocketSSELong PollingHTTP/2 Push
DirectionBidirectionalServer→ClientPseudo-bidirectionalServer→Client
ReconnectionManualAutomaticManualN/A
Binary dataNativeBase64Via HTTPVia HTTP
HTTP/2 compatibleNo (separate connection)Yes (multiplexed)YesYes
Proxy/CDN supportRequires upgradeFull HTTPFull HTTPFull HTTP
Connection limit impactPer connectionPer connectionPer requestShared
Implementation complexityHighLowModerateHigh
Browser supportUniversalUniversal (IE: polyfill)UniversalUniversal

Advanced Patterns and Techniques

Hybrid WebSocket + SSE Architecture

class HybridTransport {
  constructor(config) {
    this.config = config;
    this.transport = null;
  }
 
  async connect() {
    try {
      // Try WebSocket first for best performance
      this.transport = await this.connectWebSocket();
    } catch {
      // Fallback to SSE for reliability
      this.transport = this.connectSSE();
    }
    return this.transport;
  }
 
  connectWebSocket() {
    return new Promise((resolve, reject) => {
      const ws = new WebSocket(this.config.wsUrl);
      const timeout = setTimeout(() => {
        ws.close();
        reject(new Error('WebSocket timeout'));
      }, 5000);
 
      ws.onopen = () => {
        clearTimeout(timeout);
        resolve(new WebSocketTransport(ws));
      };
      ws.onerror = () => reject(new Error('WebSocket failed'));
    });
  }
 
  connectSSE() {
    const sse = new EventSource(this.config.sseUrl);
    return new SSETransport(sse, this.config.httpUrl);
  }
}

Cross-Tab Connection Sharing

class SharedSSEConnection {
  constructor(url) {
    this.url = url;
    this.channel = new BroadcastChannel('sse-shared');
    this.eventSource = null;
    this.isLeader = false;
    
    this.electLeader();
  }
 
  electLeader() {
    const lock = new Lock('sse-leader');
    lock.acquire().then(() => {
      this.isLeader = true;
      this.eventSource = new EventSource(this.url);
      this.eventSource.onmessage = (event) => {
        this.channel.postMessage(JSON.parse(event.data));
      };
    });
  }
}

Testing Strategies

describe('WebSocket vs SSE Comparison Tests', () => {
  it('SSE reconnects automatically', async () => {
    const events = [];
    const es = new EventSource('/api/events');
    es.onmessage = (e) => events.push(JSON.parse(e.data));
    
    // Simulate server restart
    await restartServer();
    await waitFor(() => events.length > 0, { timeout: 10000 });
    
    // EventSource should have reconnected and received events
    expect(events.length).toBeGreaterThan(0);
    es.close();
  });
 
  it('WebSocket requires manual reconnection', async () => {
    const client = new WebSocketClient('ws://localhost:8080');
    await client.waitForOpen();
    
    // Simulate server restart
    await restartServer();
    
    // Without reconnection logic, WebSocket stays closed
    await waitFor(() => client.ws.readyState === WebSocket.CLOSED);
    
    // With reconnection logic
    client.enableAutoReconnect();
    await restartServer();
    await waitFor(() => client.ws.readyState === WebSocket.OPEN);
  });
});

Future Outlook

The real-time communication landscape continues to evolve. WebTransport, built on HTTP/3 and QUIC, offers unreliable datagrams alongside reliable streams, making it suitable for gaming and media applications that need both. However, WebSocket and SSE remain the pragmatic choices for the vast majority of web applications. HTTP/3 adoption improves SSE's performance by eliminating head-of-line blocking, and WebSocket's universal browser support ensures its continued relevance. The hybrid approach — using both technologies where each excels — is likely the dominant pattern for the foreseeable future.

Conclusion

WebSocket and SSE are complementary technologies, not competing ones. WebSocket excels at low-latency bidirectional communication where both client and server send frequent messages. SSE excels at reliable server-to-client streaming with automatic reconnection and Last-Event-ID support.

Key takeaways:

  1. Use SSE by default for server-to-client push — it's simpler, more reliable, and works seamlessly with HTTP infrastructure.
  2. Use WebSocket when you need true bidirectional communication — chat, gaming, collaboration, and other scenarios requiring low-latency client-to-server messages.
  3. Implement heartbeats for both — proxies silently drop idle connections.
  4. SSE's reconnection is a killer feature — automatic reconnection with Last-Event-ID eliminates an entire class of reliability bugs.
  5. Consider a hybrid approach — use SSE for data streams and HTTP POST for client actions, combining simplicity with functionality.