Step 1: Define Transport Boundaries
Separate server-to-client pushes from client-to-server mutations. Unidirectional data (notifications, AI tokens, metrics) should use SSE or HTTP/2 streaming. Bidirectional state (chat messages, cursor positions, game ticks) requires WebSockets. This separation allows independent scaling, different timeout policies, and CDN compatibility for streaming endpoints.
Step 2: Implement the SSE Endpoint
Server-Sent Events run over standard HTTP. The server sets Content-Type: text/event-stream, disables caching, and writes formatted event blocks. Browsers handle reconnection automatically via the retry field.
import { createServer, IncomingMessage, ServerResponse } from 'http';
interface StreamPayload {
id: string;
type: 'update' | 'heartbeat' | 'error';
data: Record<string, unknown>;
}
class EventStreamController {
private clients: Set<ServerResponse> = new Set();
subscribe(req: IncomingMessage, res: ServerResponse): void {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
this.clients.add(res);
req.on('close', () => this.clients.delete(res));
res.on('finish', () => this.clients.delete(res));
// Initial connection acknowledgment
this.pushToClient(res, {
id: crypto.randomUUID(),
type: 'update',
data: { status: 'connected' }
});
}
broadcast(payload: StreamPayload): void {
const formatted = `id: ${payload.id}\nevent: ${payload.type}\ndata: ${JSON.stringify(payload.data)}\n\n`;
for (const client of this.clients) {
if (!client.writableEnded) {
client.write(formatted);
}
}
}
private pushToClient(res: ServerResponse, payload: StreamPayload): void {
if (!res.writableEnded) {
res.write(`id: ${payload.id}\nevent: ${payload.type}\ndata: ${JSON.stringify(payload.data)}\n\n`);
}
}
}
const streamController = new EventStreamController();
const server = createServer((req, res) => {
if (req.url === '/stream/telemetry') {
streamController.subscribe(req, res);
} else {
res.writeHead(404);
res.end();
}
});
server.listen(3000, () => console.log('Realtime service running on :3000'));
Step 3: Implement the WebSocket Broker
Bidirectional communication requires explicit message framing, connection lifecycle management, and application-level heartbeats. Unlike SSE, WebSocket clients do not auto-reconnect; the application must handle reconnection logic.
import { WebSocketServer, WebSocket } from 'ws';
interface BrokerMessage {
channel: string;
payload: unknown;
timestamp: number;
}
class BidirectionalBroker {
private wss: WebSocketServer;
private activeSessions: Map<string, WebSocket> = new Map();
constructor(port: number) {
this.wss = new WebSocketServer({ port });
this.setupListeners();
}
private setupListeners(): void {
this.wss.on('connection', (socket: WebSocket, req) => {
const sessionId = crypto.randomUUID();
this.activeSessions.set(sessionId, socket);
socket.on('message', (raw: Buffer) => {
try {
const message: BrokerMessage = JSON.parse(raw.toString());
this.routeMessage(sessionId, message);
} catch {
socket.send(JSON.stringify({ error: 'Invalid frame format' }));
}
});
socket.on('close', () => this.activeSessions.delete(sessionId));
socket.on('error', () => this.activeSessions.delete(sessionId));
// Application-level heartbeat
const heartbeat = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
socket.ping();
} else {
clearInterval(heartbeat);
}
}, 30000);
});
}
private routeMessage(originId: string, message: BrokerMessage): void {
const response: BrokerMessage = {
channel: message.channel,
payload: { acknowledged: true, origin: originId },
timestamp: Date.now()
};
this.broadcast(response);
}
broadcast(message: BrokerMessage): void {
const frame = JSON.stringify(message);
for (const socket of this.activeSessions.values()) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(frame);
}
}
}
}
new BidirectionalBroker(3001);
Step 4: Architecture Rationale
- Separation of concerns: SSE handles high-volume, unidirectional pushes without connection state tracking. WebSockets manage bidirectional sync where message ordering and acknowledgment matter.
- Infrastructure alignment: SSE endpoints run on stateless HTTP workers behind standard load balancers. WebSocket endpoints require sticky sessions or connection-aware proxies.
- Client reliability: Browsers natively retry SSE connections on network interruption. WebSocket reconnection must be implemented manually, increasing client-side complexity.
- CDN compatibility: Edge networks cache and proxy HTTP streams efficiently. WebSocket upgrades bypass most CDN features, forcing traffic directly to origin servers.
Pitfall Guide
1. Treating SSE as a Bidirectional Transport
Explanation: Developers attempt to send client-to-server data through the same SSE endpoint using query parameters or POST requests, breaking the unidirectional contract.
Fix: Keep SSE strictly server-to-client. Use standard REST/GraphQL endpoints for client mutations, or switch to WebSockets if bidirectional sync is required.
2. Ignoring Proxy Timeout Limits
Explanation: Nginx, Cloudflare, and AWS ALB default to 60-second idle timeouts. Long-lived SSE or WebSocket connections terminate silently, causing data loss.
Fix: Configure proxy timeouts to exceed application heartbeat intervals. Set proxy_read_timeout 3600s in Nginx, or use CDN streaming profiles that disable idle timeouts for /stream/* paths.
3. Memory Leaks from Uncleaned Event Listeners
Explanation: Client-side EventSource or WebSocket listeners accumulate when components unmount without cleanup, causing duplicate message processing and memory growth.
Fix: Implement explicit teardown routines. In React, use useEffect cleanup functions to call eventSource.close() or socket.close(). Track active connections in a centralized registry.
4. Over-Provisioning WebSocket Servers for Unidirectional Data
Explanation: Deploying WebSocket infrastructure for dashboards or notification feeds forces stateful scaling, increasing memory usage and load balancer complexity.
Fix: Route unidirectional traffic through HTTP/2 streaming or SSE. Reserve WebSocket clusters exclusively for bidirectional use cases. Monitor connection counts and scale independently.
5. Missing Application-Level Heartbeats
Explanation: Relying solely on TCP keepalive or proxy timeouts leads to zombie connections. Firewalls and NAT gateways drop idle TCP streams without notifying endpoints.
Fix: Implement application-level ping/pong or SSE retry fields. Send lightweight frames every 20β30 seconds. Track last-activity timestamps and force reconnect on timeout.
6. Misconfiguring Load Balancers for WebSocket Upgrades
Explanation: Standard HTTP load balancers drop Upgrade: websocket headers, causing handshake failures. Sticky sessions are not configured, routing subsequent frames to different backends.
Fix: Enable WebSocket protocol support at the load balancer. Configure connection stickiness based on session ID or IP hash. Verify Connection: Upgrade and Upgrade: websocket headers are forwarded intact.
7. Assuming WebRTC Works Without Signaling Infrastructure
Explanation: Teams attempt direct peer connections without implementing a signaling server for SDP exchange and ICE candidate routing.
Fix: Deploy a lightweight signaling service (WebSocket or HTTP) to exchange session descriptions. Use TURN servers for NAT traversal. Treat WebRTC as a data channel, not a connection manager.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Live metrics dashboard | SSE | Native browser reconnection, stateless HTTP workers, CDN compatible | Low (scales horizontally with zero sticky sessions) |
| Collaborative document editing | WebSockets | Requires bidirectional sync, conflict resolution, and low-latency state sharing | Medium (requires sticky routing and connection tracking) |
| AI response streaming | HTTP/2 Streaming or SSE | Token-by-token delivery benefits from multiplexing and standard HTTP caching | Low (runs on existing API infrastructure) |
| Legacy notification service | Long Polling | Compatible with older browsers and restrictive corporate proxies | Medium (higher request volume, but zero protocol changes) |
| Video conferencing | WebRTC | Direct peer-to-peer media transfer reduces server bandwidth costs | High (requires TURN/STUN infrastructure, but saves egress fees) |
Configuration Template
Nginx Reverse Proxy Configuration
upstream realtime_backend {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
}
server {
listen 443 ssl http2;
server_name api.example.com;
# SSE Streaming Endpoint
location /stream/ {
proxy_pass http://realtime_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache off;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# WebSocket Broker Endpoint
location /ws/ {
proxy_pass http://realtime_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
TypeScript Runtime Configuration
export const RealtimeConfig = {
sse: {
endpoint: '/stream/telemetry',
heartbeatInterval: 25000,
retryDelay: 3000,
maxRetries: 5
},
websocket: {
endpoint: 'wss://api.example.com/ws',
heartbeatInterval: 30000,
reconnectDelay: 2000,
maxReconnectAttempts: 10
},
proxy: {
timeout: 3600,
buffering: false,
httpVersion: 1.1
}
} as const;
Quick Start Guide
- Initialize the service: Create a new Node.js project, install
ws and typescript, and add the EventStreamController and BidirectionalBroker classes to separate modules.
- Configure routing: Set up an HTTP server that routes
/stream/* to the SSE controller and /ws/* to the WebSocket broker. Apply the Nginx configuration above for production proxying.
- Implement client consumers: Use
new EventSource('/stream/telemetry') for dashboard updates. Use new WebSocket('wss://api.example.com/ws') with manual reconnection logic for bidirectional features.
- Deploy and monitor: Run the service behind a load balancer with WebSocket protocol support enabled. Track connection counts, heartbeat success rates, and proxy timeout logs. Adjust heartbeat intervals based on observed network behavior.