Cloud and platform

WebSocket vs Server-Sent Events: Choosing the Right Real-Time Technology for Production

WebSocket and SSE both solve real-time communication, but they're not interchangeable. Understanding the architectural differences prevents performance bottlenecks and operational complexity.

3/10/20267 min readCloud
WebSocket vs Server-Sent Events: Choosing the Right Real-Time Technology for Production

Executive summary

WebSocket and SSE both solve real-time communication, but they're not interchangeable. Understanding the architectural differences prevents performance bottlenecks and operational complexity.

Last updated: 3/10/2026

Executive summary

Real-time functionality—live updates, push notifications, collaborative editing—has moved from "nice-to-have" to table stakes for modern web applications. The two dominant approaches for browser-to-server real-time communication are WebSocket and Server-Sent Events (SSE).

The engineering decision isn't just technical; it's architectural. WebSocket provides full-duplex communication, while SSE is unidirectional server-to-client push. Both have distinct scaling characteristics, operational requirements, and failure modes. Choosing the wrong one leads to unnecessary infrastructure complexity, security vulnerabilities, or poor user experience.

Connection models: Understanding the fundamental difference

WebSocket: Full-duplex bidirectional communication

WebSocket upgrades an HTTP connection to a persistent TCP socket that allows data to flow in both directions simultaneously.

typescript// WebSocket client implementation
class RealtimeChatClient {
  private ws: WebSocket;
  private reconnectAttempts = 0;
  private readonly MAX_RECONNECT_ATTEMPTS = 5;
  private readonly RECONNECT_DELAY_MS = 3000;

  constructor(private url: string) {
    this.ws = this.connect();
  }

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

    ws.onopen = () => {
      console.log('WebSocket connected');
      this.reconnectAttempts = 0;
      // Send authentication
      this.sendMessage({
        type: 'auth',
        token: this.getAuthToken()
      });
    };

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

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

    ws.onclose = (event) => {
      console.log('WebSocket closed:', event.code, event.reason);
      if (!event.wasClean && this.reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS) {
        this.reconnectAttempts++;
        setTimeout(() => {
          this.ws = this.connect();
        }, this.RECONNECT_DELAY_MS);
      }
    };

    return ws;
  }

  sendMessage(message: any) {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(message));
    } else {
      console.error('WebSocket is not open');
    }
  }

  private handleMessage(message: any) {
    switch (message.type) {
      case 'chat_message':
        this.displayMessage(message.data);
        break;
      case 'presence_update':
        this.updatePresence(message.data);
        break;
      case 'typing_indicator':
        this.showTypingIndicator(message.data);
        break;
    }
  }

  private getAuthToken(): string {
    return localStorage.getItem('auth_token') || '';
  }

  private displayMessage(data: any) {
    // Update UI with new message
  }

  private updatePresence(data: any) {
    // Update user presence indicators
  }

  private showTypingIndicator(data: any) {
    // Show/hide typing indicator
  }
}

Server-Sent Events: Unidirectional server-to-client push

SSE uses a long-lived HTTP connection where the server pushes events to the client. The client cannot send data back through the SSE connection.

typescript// SSE client implementation
class LiveUpdatesClient {
  private eventSource: EventSource;
  private reconnectAttempts = 0;
  private readonly MAX_RECONNECT_ATTEMPTS = 5;

  constructor(private url: string) {
    this.eventSource = this.connect();
  }

  private connect(): EventSource {
    const eventSource = new EventSource(this.url);

    eventSource.onopen = () => {
      console.log('SSE connection established');
      this.reconnectAttempts = 0;
    };

    eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data);
      this.handleUpdate(data);
    };

    eventSource.onerror = (error) => {
      console.error('SSE error:', error);
      this.eventSource.close();

      if (this.reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS) {
        this.reconnectAttempts++;
        setTimeout(() => {
          this.eventSource = this.connect();
        }, 3000);
      }
    };

    return eventSource;
  }

  private handleUpdate(data: any) {
    switch (data.type) {
      case 'price_update':
        this.updatePrice(data);
        break;
      case 'notification':
        this.showNotification(data);
        break;
      case 'system_status':
        this.updateStatus(data);
        break;
    }
  }

  private updatePrice(data: any) {
    // Update price in UI
  }

  private showNotification(data: any) {
    // Display toast notification
  }

  private updateStatus(data: any) {
    // Update system status indicator
  }

  // Note: Cannot send data back through SSE connection
  // Use fetch/HTTP for client-to-server communication
}

Comparison matrix: When to use which

CriterionWebSocketSSE (Server-Sent Events)
DirectionalityBidirectional (server ↔ client)Unidirectional (server → client only)
Data formatBinary or textText-only (typically JSON)
Browser supportExcellent (all modern browsers)Excellent (all modern browsers)
Proxy compatibilityMay require special configurationWorks through most proxies
ReconnectionManual implementation requiredAutomatic with exponential backoff
Event typesCustom message typesNamed event channels
Connection overheadInitial HTTP handshake + upgradePersistent HTTP connection
Scaling complexityHigh (connection pooling, state management)Moderate (stateless easier)
Use case fitChat, gaming, collaborative editingLive updates, notifications, stock tickers

Use case analysis

When WebSocket is the right choice

1. Real-time chat applications

Chat requires bidirectional communication: users send messages and receive messages instantly. WebSocket's full-duplex nature is ideal.

typescript// Chat application using WebSocket
class ChatApplication {
  private ws: WebSocket;
  private currentUser: User;

  async sendMessage(content: string) {
    this.ws.send(JSON.stringify({
      type: 'send_message',
      data: {
        userId: this.currentUser.id,
        content,
        timestamp: Date.now()
      }
    }));
  }

  private handleIncomingMessage(message: ChatMessage) {
    // Display incoming message
    // Update unread count
    // Trigger notification if needed
  }

  async setTypingStatus(isTyping: boolean) {
    this.ws.send(JSON.stringify({
      type: 'typing_status',
      data: {
        userId: this.currentUser.id,
        isTyping,
        timestamp: Date.now()
      }
    }));
  }

  private handleTypingStatus(status: TypingStatus) {
    // Show/hide typing indicator
  }
}

2. Multiplayer gaming

Games require frequent, low-latency bidirectional communication with multiple concurrent events.

3. Collaborative editing

Tools like Google Docs or Figma require real-time synchronization of user actions across all participants.

When SSE is the right choice

1. Live financial data updates

Stock prices, cryptocurrency values, or any financial tickers only need server-to-client push.

typescript// Financial ticker using SSE
class StockTickerClient {
  private eventSource: EventSource;
  private subscribedSymbols: Set<string> = new Set();

  async subscribe(symbols: string[]) {
    // Use HTTP POST to subscribe (not SSE)
    await fetch('/api/stocks/subscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ symbols })
    });

    symbols.forEach(symbol => this.subscribedSymbols.add(symbol));
  }

  async unsubscribe(symbols: string[]) {
    await fetch('/api/stocks/unsubscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ symbols })
    });

    symbols.forEach(symbol => this.subscribedSymbols.delete(symbol));
  }

  private handlePriceUpdate(update: PriceUpdate) {
    if (this.subscribedSymbols.has(update.symbol)) {
      this.updateDisplay(update);
    }
  }

  private updateDisplay(update: PriceUpdate) {
    // Update price in UI
    // Highlight price change (green/red)
    // Update chart
  }
}

2. Real-time notifications

Push notifications, alerts, or system status updates flow from server to client only.

3. Live dashboard monitoring

Operational dashboards displaying metrics, logs, or system status only need server-to-client updates.

Scaling considerations

WebSocket scaling challenges

WebSocket connections are stateful and persistent, which creates scaling complexity:

1. Connection pooling and state management

typescript// WebSocket server with connection pool
class WebSocketServer {
  private connections: Map<string, WebSocket> = new Map();
  private userRooms: Map<string, Set<string>> = new Map(); // userId -> roomIds

  handleConnection(userId: string, ws: WebSocket) {
    this.connections.set(userId, ws);

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

    ws.on('close', () => {
      this.connections.delete(userId);
      this.removeUserFromAllRooms(userId);
    });
  }

  broadcastToRoom(roomId: string, message: any) {
    const usersInRoom = this.getUserIdsInRoom(roomId);
    usersInRoom.forEach(userId => {
      const ws = this.connections.get(userId);
      if (ws && ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify(message));
      }
    });
  }

  private getUserIdsInRoom(roomId: string): string[] {
    // Return all user IDs in the room
    return Array.from(this.userRooms.entries())
      .filter(([_, rooms]) => rooms.has(roomId))
      .map(([userId, _]) => userId);
  }

  private removeUserFromAllRooms(userId: string) {
    const rooms = this.userRooms.get(userId);
    if (rooms) {
      rooms.forEach(roomId => this.leaveRoom(userId, roomId));
    }
    this.userRooms.delete(userId);
  }
}

2. Load balancer compatibility

WebSocket connections require load balancers that support sticky sessions or implement WebSocket-aware routing.

3. Horizontal scaling across multiple servers

When WebSocket connections are distributed across multiple servers, inter-server communication is required for broadcasting:

typescript// Redis-based pub/sub for cross-server WebSocket broadcasting
class DistributedWebSocketServer {
  private connections: Map<string, WebSocket> = new Map();
  private redisPublisher: Redis;
  private redisSubscriber: Redis;

  constructor(private serverId: string) {
    this.redisSubscriber.subscribe('websocket:broadcast');
    this.redisSubscriber.on('message', (channel, message) => {
      this.handleBroadcastMessage(JSON.parse(message));
    });
  }

  async broadcastToAll(message: any) {
    // Publish to Redis for all servers to receive
    await this.redisPublisher.publish('websocket:broadcast', JSON.stringify({
      serverId: this.serverId,
      message
    }));
  }

  private handleBroadcastMessage(data: any) {
    // Don't rebroadcast if message originated from this server
    if (data.serverId === this.serverId) {
      return;
    }

    // Send to all connected clients on this server
    this.connections.forEach(ws => {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify(data.message));
      }
    });
  }
}

SSE scaling advantages

SSE is easier to scale because:

1. Stateless server design

SSE servers can be stateless and horizontally scaled behind standard load balancers.

2. Built-in reconnection

The EventSource API automatically handles reconnection with exponential backoff, reducing server-side complexity.

3. Simple horizontal scaling

Multiple SSE servers can serve different clients without inter-server communication required for broadcasting.

typescript// Stateless SSE server
class SSEServer {
  private clients: Map<http.ServerResponse, Set<string>> = new Map();

  handleSSEConnection(req: http.IncomingMessage, res: http.ServerResponse) {
    // Set SSE headers
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      'X-Accel-Buffering': 'no' // Disable nginx buffering
    });

    // Track client and their subscriptions
    this.clients.set(res, new Set());

    // Send initial connection message
    this.sendEvent(res, { type: 'connected', timestamp: Date.now() });

    req.on('close', () => {
      this.clients.delete(res);
    });
  }

  sendEvent(res: http.ServerResponse, data: any) {
    const event = `data: ${JSON.stringify(data)}\n\n`;
    res.write(event);
  }

  broadcastToAll(message: any) {
    this.clients.forEach((_, res) => {
      this.sendEvent(res, message);
    });
  }
}

Security considerations

WebSocket security

1. Authentication during handshake

typescript// WebSocket authentication middleware
const authenticateWebSocket = async (req: http.IncomingRequest): Promise<User | null> => {
  const token = req.headers.authorization?.replace('Bearer ', '');

  if (!token) {
    return null;
  }

  try {
    const decoded = await verifyJWT(token);
    return await User.findById(decoded.userId);
  } catch (error) {
    return null;
  }
};

// WebSocket server with authentication
wss.on('connection', async (ws: WebSocket, req: http.IncomingRequest) => {
  const user = await authenticateWebSocket(req);

  if (!user) {
    ws.close(4008, 'Unauthorized');
    return;
  }

  // Store user connection
  connections.set(user.id, ws);

  ws.on('message', (message) => {
    handleUserMessage(user.id, message);
  });
});

2. Rate limiting and abuse prevention

WebSocket connections can be abused for DoS attacks or resource exhaustion.

typescript// Rate limiting for WebSocket messages
class WebSocketRateLimiter {
  private messageCounts: Map<string, number[]> = new Map();
  private readonly WINDOW_MS = 60000; // 1 minute
  private readonly MAX_MESSAGES = 100;

  checkRateLimit(userId: string): boolean {
    const now = Date.now();
    const userMessages = this.messageCounts.get(userId) || [];

    // Remove messages outside the time window
    const recentMessages = userMessages.filter(timestamp => now - timestamp < this.WINDOW_MS);

    if (recentMessages.length >= this.MAX_MESSAGES) {
      return false;
    }

    recentMessages.push(now);
    this.messageCounts.set(userId, recentMessages);
    return true;
  }
}

SSE security

1. Authentication via query parameters or cookies

typescript// SSE server with authentication
const handleSSEConnection = async (req: http.IncomingRequest, res: http.ServerResponse) => {
  // Extract token from cookie or query parameter
  const token = req.headers.cookie?.match(/auth_token=([^;]+)/)?.[1]
            || new URL(req.url || '', 'http://localhost').searchParams.get('token');

  if (!token) {
    res.writeHead(401);
    res.end();
    return;
  }

  const user = await authenticateToken(token);
  if (!user) {
    res.writeHead(401);
    res.end();
    return;
  }

  // Establish SSE connection
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive'
  });

  // Send initial event
  res.write(`data: ${JSON.stringify({ type: 'connected', userId: user.id })}\n\n`);

  // Continue sending events...
};

2. Authorization and scope-based filtering

Clients should only receive events they're authorized to see.

Performance and resource considerations

WebSocket resource usage

Connection overhead:

  • Each WebSocket connection holds a TCP socket open
  • Server memory: ~1-2KB per connection (connection state)
  • Server CPU: minimal for idle connections
  • Network: keep-alive packets (typically every 30-60 seconds)

Benchmarking example:

typescript// WebSocket connection stress test
async function benchmarkWebSocketConnections() {
  const CONCURRENT_CONNECTIONS = 10000;
  const clients: WebSocket[] = [];

  console.time('connect');
  for (let i = 0; i < CONCURRENT_CONNECTIONS; i++) {
    const ws = new WebSocket('ws://localhost:8080');
    clients.push(ws);
    await new Promise(resolve => ws.onopen = resolve);
  }
  console.timeEnd('connect');

  // Measure memory usage
  const memoryUsage = process.memoryUsage();
  console.log('Memory usage:', {
    heapUsed: `${Math.round(memoryUsage.heapUsed / 1024 / 1024)} MB`,
    heapTotal: `${Math.round(memoryUsage.heapTotal / 1024 / 1024)} MB`
  });
}

SSE resource usage

Connection overhead:

  • Each SSE connection holds an HTTP connection open
  • Server memory: ~500B-1KB per connection (less than WebSocket)
  • Server CPU: minimal for idle connections
  • Network: keep-alive packets (typically every 30-60 seconds)

Memory efficiency advantage: SSE connections are typically lighter on memory because they don't maintain as much state as WebSocket connections.

Hybrid approaches

In many production systems, the optimal solution combines both technologies based on use case:

typescript// Hybrid real-time client
class HybridRealtimeClient {
  private sseClient: LiveUpdatesClient;
  private wsClient: RealtimeChatClient;
  private wsConnected = false;

  constructor(private config: HybridConfig) {
    // Initialize SSE for server-to-client updates
    this.sseClient = new LiveUpdatesClient(config.sseUrl);

    // Initialize WebSocket for bidirectional communication
    this.wsClient = new RealtimeChatClient(config.wsUrl);

    this.wsClient.onConnectionChange = (connected: boolean) => {
      this.wsConnected = connected;
    };
  }

  sendChatMessage(message: string) {
    // Use WebSocket for sending messages
    if (this.wsConnected) {
      this.wsClient.sendMessage(message);
    } else {
      console.error('WebSocket not connected');
      // Fallback: use HTTP POST
      this.postMessageViaHTTP(message);
    }
  }

  private async postMessageViaHTTP(message: string) {
    await fetch('/api/chat/messages', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content: message })
    });
  }

  subscribeToLiveUpdates() {
    // Use SSE for receiving updates
    this.sseClient.connect();
  }
}

Decision framework

Use this framework to choose the right technology for your use case:

Questions to ask

  1. Does the client need to send data to the server in real-time?
  • Yes → WebSocket
  • No → Consider SSE
  1. Is low latency critical for both directions?
  • Yes → WebSocket
  • No → SSE may be sufficient
  1. How many concurrent connections do you expect?
  • < 10,000 → Both options viable
  • 10,000-100,000 → SSE may scale easier
  • > 100,000 → Requires specialized WebSocket infrastructure
  1. What's your team's operational expertise?
  • Experienced with WebSocket infrastructure → WebSocket
  • Prefering simpler stateless architecture → SSE
  1. Do you need binary data transfer?
  • Yes → WebSocket
  • No → SSE sufficient

Production deployment checklist

For WebSocket:

  • [ ] Implement connection pooling and state management
  • [ ] Configure load balancers for sticky sessions
  • [ ] Implement cross-server communication for broadcasting
  • [ ] Add rate limiting and abuse prevention
  • [ ] Set up monitoring for connection health
  • [ ] Implement graceful reconnection on client side
  • [ ] Add connection timeout handling

For SSE:

  • [ ] Implement authentication via cookies or query parameters
  • [ ] Set proper SSE headers (Content-Type, Cache-Control)
  • [ ] Configure reverse proxies to disable buffering
  • [ ] Add authorization and scope-based filtering
  • [ ] Implement event replay for disconnected clients
  • [ ] Set up monitoring for connection health

Conclusion

WebSocket and SSE are both powerful technologies for real-time communication, but they solve different problems. WebSocket excels at bidirectional, low-latency communication where both client and server need to exchange data frequently. SSE shines in unidirectional server-to-client scenarios where simplicity and stateless scaling are priorities.

The decision isn't just about technical capabilities—it's about operational complexity, team expertise, and long-term maintainability. Choose the technology that matches your use case, scaling requirements, and operational capabilities.


Building a real-time application and need guidance on architecture decisions? Talk to Imperialis about designing and implementing the right real-time solution for your production system.

Sources

Related reading