Skip to main content

WebSockets vs Server-Sent Events vs HTTP/2 Push: Real-time Communication Guide

Master real-time web communication by comparing WebSockets, Server-Sent Events, and HTTP/2 Server Push. Learn when to use each technology with practical examples and performance insights.

Table of Contents

Introduction

Real-time communication has become a fundamental requirement for modern web applications. Whether you’re building a chat application, live dashboard, collaborative editor, or real-time notifications system, choosing the right technology for bidirectional or unidirectional data flow can make or break your application’s performance and user experience.

The web offers several technologies for real-time communication, each with distinct characteristics, use cases, and trade-offs. WebSockets provide full-duplex communication channels, Server-Sent Events (SSE) enable efficient one-way server-to-client messaging, and HTTP/2 Server Push offers resource preloading capabilities. Understanding when and why to use each technology is crucial for building scalable, performant applications.

This comprehensive guide will help you understand the differences between WebSockets, Server-Sent Events, and HTTP/2 Server Push. You’ll learn how each technology works, when to use them, and how to implement them effectively. We’ll cover practical examples, performance considerations, and real-world patterns that will help you make informed architectural decisions.

By the end of this guide, you’ll be able to evaluate your application’s real-time communication needs and choose the most appropriate technology, whether you’re building a simple notification system or a complex multiplayer game.


Understanding Real-time Communication

Before diving into specific technologies, it’s essential to understand what real-time communication means in web applications and why traditional HTTP requests fall short.

The HTTP Request-Response Limitation

Traditional HTTP follows a request-response pattern: the client sends a request, waits for the server to respond, and the connection closes. This works well for static content and simple interactions, but it has significant limitations for real-time applications:

  • No server-initiated communication: The server cannot push data to the client without an explicit request
  • Overhead: Each request requires establishing a new connection (unless HTTP keep-alive is used)
  • Latency: Polling (repeatedly requesting updates) introduces unnecessary delay and server load
  • Inefficiency: Long polling (keeping requests open) consumes resources even when no data is available

Real-time Communication Requirements

Real-time applications typically need:

  1. Low latency: Data should reach clients as quickly as possible
  2. Efficient resource usage: Minimal overhead for maintaining connections
  3. Scalability: Ability to handle many concurrent connections
  4. Reliability: Automatic reconnection and error handling
  5. Bi-directional or uni-directional: Depending on use case, you may need two-way or one-way communication

The Evolution of Real-time Web Technologies

The web has evolved several solutions to address these needs:

  • WebSockets: Full-duplex communication over a single TCP connection
  • Server-Sent Events: One-way server-to-client streaming over HTTP
  • HTTP/2 Server Push: Server-initiated resource delivery (though deprecated in HTTP/3)

Each technology addresses different use cases and has unique characteristics that make them suitable for specific scenarios.


WebSockets: Full-Duplex Communication

WebSockets provide a persistent, full-duplex communication channel between client and server. Once established, both parties can send messages at any time without the overhead of HTTP headers.

How WebSockets Work

WebSockets start with an HTTP handshake that upgrades the connection to the WebSocket protocol (ws:// or wss://). After the upgrade, the connection remains open, allowing bidirectional communication with minimal overhead.

Key Characteristics:

  • Full-duplex: Both client and server can send messages simultaneously
  • Persistent connection: Single TCP connection stays open
  • Low overhead: After handshake, only data frames are sent (2-14 bytes overhead)
  • Binary and text support: Can send both text and binary data
  • Protocol-level: Operates at a lower level than HTTP

WebSocket Handshake Process

// Client-side WebSocket connection
const ws = new WebSocket("wss://example.com/chat");
// Connection opened
ws.onopen = () => {
console.log("WebSocket connection established");
// Send a message to the server
ws.send(JSON.stringify({ type: "join", room: "general" }));
};
// Receive messages from server
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("Received:", data);
// Handle different message types
if (data.type === "message") {
displayMessage(data.content);
} else if (data.type === "userJoined") {
updateUserList(data.users);
}
};
// Handle errors
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
// Handle connection close
ws.onclose = (event) => {
console.log("Connection closed:", event.code, event.reason);
// Implement reconnection logic
setTimeout(() => reconnect(), 1000);
};
// Send message to server
function sendMessage(text) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "message", content: text }));
}
}

Server-Side WebSocket Implementation

// Node.js with ws library
const WebSocket = require("ws");
const wss = new WebSocket.Server({ port: 8080 });
// Store connected clients
const clients = new Map();
wss.on("connection", (ws, req) => {
const clientId = generateClientId();
clients.set(clientId, ws);
console.log(`Client ${clientId} connected`);
// Send welcome message
ws.send(
JSON.stringify({
type: "welcome",
clientId: clientId,
timestamp: Date.now(),
}),
);
// Handle incoming messages
ws.on("message", (message) => {
try {
const data = JSON.parse(message);
// Broadcast to all clients
broadcast({
type: "message",
from: clientId,
content: data.content,
timestamp: Date.now(),
});
} catch (error) {
ws.send(
JSON.stringify({
type: "error",
message: "Invalid message format",
}),
);
}
});
// Handle disconnection
ws.on("close", () => {
clients.delete(clientId);
broadcast({
type: "userLeft",
clientId: clientId,
});
console.log(`Client ${clientId} disconnected`);
});
// Handle errors
ws.on("error", (error) => {
console.error(`WebSocket error for client ${clientId}:`, error);
});
});
function broadcast(data) {
const message = JSON.stringify(data);
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}

WebSocket Advantages ✅

  • Low latency: Minimal overhead after initial handshake
  • Full-duplex: True bidirectional communication
  • Efficient: Single persistent connection
  • Binary support: Can send binary data efficiently
  • Wide browser support: Available in all modern browsers

WebSocket Disadvantages ❌

  • Complexity: Requires connection management, reconnection logic, and error handling
  • Stateful: Server must maintain connection state
  • Proxy issues: Some proxies and firewalls may block WebSocket connections
  • No automatic reconnection: Must implement reconnection logic manually
  • Resource intensive: Each connection consumes server resources

Server-Sent Events: One-Way Server Push

Server-Sent Events (SSE) provide a simple way for servers to push data to clients over HTTP. Unlike WebSockets, SSE is unidirectional—data flows only from server to client.

How Server-Sent Events Work

SSE uses standard HTTP connections with a special text/event-stream content type. The server keeps the connection open and sends data in a specific format. The browser’s EventSource API handles reconnection automatically.

Key Characteristics:

  • Unidirectional: Server-to-client only
  • HTTP-based: Uses standard HTTP, works through proxies and firewalls
  • Automatic reconnection: Browser handles reconnection automatically
  • Text-only: Can only send text data (though JSON can be encoded)
  • Simple: Easier to implement than WebSockets

Client-Side SSE Implementation

// Create EventSource connection
const eventSource = new EventSource("/api/events");
// Listen for messages
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("Received:", data);
// Update UI based on event type
if (data.type === "notification") {
showNotification(data.message);
} else if (data.type === "update") {
updateDashboard(data.metrics);
}
};
// Listen for specific event types
eventSource.addEventListener("userJoined", (event) => {
const user = JSON.parse(event.data);
addUserToList(user);
});
eventSource.addEventListener("systemAlert", (event) => {
const alert = JSON.parse(event.data);
showAlert(alert.message, alert.severity);
});
// Handle connection opened
eventSource.onopen = () => {
console.log("SSE connection opened");
updateConnectionStatus("connected");
};
// Handle errors
eventSource.onerror = (error) => {
console.error("SSE error:", error);
updateConnectionStatus("error");
// EventSource automatically attempts to reconnect
};
// Close connection when done
function closeConnection() {
eventSource.close();
}

Server-Side SSE Implementation

// Node.js Express example
const express = require("express");
const app = express();
// Store active SSE connections
const clients = new Set();
// SSE endpoint
app.get("/api/events", (req, res) => {
// Set headers for SSE
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no"); // Disable buffering in nginx
// Add client to set
clients.add(res);
// Send initial connection message
res.write(
`data: ${JSON.stringify({
type: "connected",
timestamp: Date.now(),
})}\n\n`,
);
// Handle client disconnect
req.on("close", () => {
clients.delete(res);
console.log("Client disconnected");
});
});
// Function to broadcast to all connected clients
function broadcast(data) {
const message = `data: ${JSON.stringify(data)}\n\n`;
clients.forEach((client) => {
try {
client.write(message);
} catch (error) {
// Remove dead connections
clients.delete(client);
}
});
}
// Example: Send notification
function sendNotification(message) {
broadcast({
type: "notification",
message: message,
timestamp: Date.now(),
});
}
// Example: Send custom event type
function sendCustomEvent(eventType, data) {
const message = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
clients.forEach((client) => {
try {
client.write(message);
} catch (error) {
clients.delete(client);
}
});
}
app.listen(3000, () => {
console.log("Server running on port 3000");
});

SSE Message Format

SSE messages follow a specific format:

// Simple message
res.write("data: Hello World\n\n");
// Message with ID (for reconnection)
res.write('id: 12345\ndata: {"type": "update"}\n\n');
// Custom event type
res.write('event: userJoined\ndata: {"userId": "123"}\n\n');
// Multiple data lines (joined with \n)
res.write("data: Line 1\ndata: Line 2\ndata: Line 3\n\n");
// Comments (ignored by client)
res.write(": This is a comment\n");

SSE Advantages ✅

  • Simple implementation: Easier than WebSockets
  • Automatic reconnection: Browser handles reconnection automatically
  • HTTP-based: Works through proxies and firewalls
  • Built-in event types: Support for custom event types
  • Lower server overhead: Simpler than WebSocket connections

SSE Disadvantages ❌

  • Unidirectional: Cannot send data from client to server (must use separate HTTP requests)
  • Text-only: Limited to text data (though JSON works)
  • Connection limits: Browsers limit concurrent connections per domain
  • No binary support: Cannot send binary data efficiently

HTTP/2 Server Push: Resource Preloading

HTTP/2 Server Push allows servers to proactively send resources to clients before they’re requested. However, it’s important to note that HTTP/2 Server Push has been deprecated in HTTP/3 and is being replaced by alternative approaches.

How HTTP/2 Server Push Works

When a server receives a request for an HTML page, it can push additional resources (CSS, JavaScript, images) that it knows the client will need. This reduces round trips and improves page load performance.

Key Characteristics:

  • Resource preloading: Pushes resources before client requests them
  • HTTP/2 feature: Requires HTTP/2 connection
  • One-time push: Resources are pushed once per connection
  • Cache-aware: Respects browser cache
  • Deprecated: Removed in HTTP/3 in favor of alternative approaches

HTTP/2 Server Push Implementation

// Node.js with http2
const http2 = require("http2");
const fs = require("fs");
const server = http2.createSecureServer({
key: fs.readFileSync("server.key"),
cert: fs.readFileSync("server.cert"),
});
server.on("stream", (stream, headers) => {
const path = headers[":path"];
if (path === "/index.html") {
// Push CSS file
stream.pushStream({ ":path": "/styles.css" }, (err, pushStream) => {
if (!err) {
pushStream.respond({ ":status": 200, "content-type": "text/css" });
pushStream.end(fs.readFileSync("styles.css"));
}
});
// Push JavaScript file
stream.pushStream({ ":path": "/app.js" }, (err, pushStream) => {
if (!err) {
pushStream.respond({
":status": 200,
"content-type": "application/javascript",
});
pushStream.end(fs.readFileSync("app.js"));
}
});
// Send the HTML
stream.respond({ ":status": 200, "content-type": "text/html" });
stream.end(fs.readFileSync("index.html"));
} else {
// Handle other requests normally
stream.respond({ ":status": 404 });
stream.end();
}
});
server.listen(8443);

HTTP/2 Server Push Considerations ⚠️

Important Notes:

  • Deprecated in HTTP/3: HTTP/2 Server Push is not available in HTTP/3
  • Cache complexity: Can push resources that are already cached
  • Bandwidth waste: May push resources the client doesn’t need
  • Limited use cases: Primarily useful for initial page loads
  • Alternative approaches: Consider <link rel="preload"> or Early Hints instead

Modern Alternatives to Server Push

<!-- Preload critical resources -->
<link rel="preload" href="/critical.css" as="style" />
<link rel="preload" href="/app.js" as="script" />
<!-- Preconnect to external domains -->
<link rel="preconnect" href="https://api.example.com" />
<!-- DNS prefetch -->
<link rel="dns-prefetch" href="https://cdn.example.com" />

Comparing the Technologies

Understanding the differences between these technologies helps you choose the right one for your use case.

Feature Comparison Table

FeatureWebSocketsServer-Sent EventsHTTP/2 Push
DirectionBidirectionalUnidirectional (server→client)Unidirectional (server→client)
Protocolws:// or wss://HTTP/HTTPSHTTP/2
ConnectionPersistent TCPPersistent HTTPPer-request
ReconnectionManualAutomaticN/A
Data FormatText & BinaryText onlyAny resource
OverheadLow (after handshake)Medium (HTTP headers)Medium (HTTP/2 frames)
Browser SupportExcellentExcellentHTTP/2 required
Proxy/FirewallMay be blockedUsually worksUsually works
Use CaseReal-time apps, gamesNotifications, live feedsResource preloading
ComplexityHighLowMedium

Communication Patterns

WebSockets - Full-duplex communication:

Client ←→ Server
↑ ↓
└────────┘

Server-Sent Events - One-way server push:

Client ← Server
↑ ↓
└───────┘
(Client uses separate HTTP requests to send data)

HTTP/2 Push - Resource preloading:

Client → Server (request HTML)
Server → Client (push CSS, JS, images)

Performance Characteristics

Latency:

  • WebSockets: Lowest latency after connection established (~1-2ms per message)
  • SSE: Low latency (~10-50ms depending on network)
  • HTTP/2 Push: Reduces initial page load time, not for ongoing communication

Bandwidth Efficiency:

  • WebSockets: Most efficient for frequent small messages
  • SSE: Efficient for one-way server updates
  • HTTP/2 Push: Can waste bandwidth if resources are already cached

Server Resources:

  • WebSockets: Higher memory per connection (stateful)
  • SSE: Lower memory per connection (stateless HTTP)
  • HTTP/2 Push: Minimal overhead (per-request)

Performance Considerations

Understanding performance implications helps you optimize your real-time applications.

Connection Overhead

WebSocket Handshake:

// Initial handshake includes HTTP upgrade request
// Overhead: ~200-300 bytes
// After handshake: 2-14 bytes per frame
// Example: Sending 1000 messages
// WebSocket: ~200 bytes (handshake) + 2000-14000 bytes (messages)
// Total: ~2200-14200 bytes

SSE Connection:

// Each SSE connection maintains HTTP headers
// Overhead: ~400-600 bytes per connection
// Per message: ~50-100 bytes (HTTP framing)
// Example: Sending 1000 messages
// SSE: ~600 bytes (connection) + 50000-100000 bytes (messages)
// Total: ~50600-100600 bytes

Scalability Patterns

WebSocket Scaling:

// Horizontal scaling requires sticky sessions or message broker
const Redis = require("ioredis");
const redis = new Redis();
// Publish message to all servers
function broadcastToAllServers(message) {
redis.publish("messages", JSON.stringify(message));
}
// Each server subscribes and broadcasts to its clients
redis.subscribe("messages");
redis.on("message", (channel, message) => {
const data = JSON.parse(message);
broadcastToClients(data);
});

SSE Scaling:

// SSE can scale more easily with load balancers
// Stateless HTTP connections work well with round-robin
// Use Redis pub/sub for cross-server communication
const redis = require("ioredis");
const subscriber = new Redis();
subscriber.subscribe("events");
subscriber.on("message", (channel, message) => {
const event = JSON.parse(message);
broadcastToSSEClients(event);
});

Memory and CPU Usage

WebSocket Memory:

  • Each connection: ~2-10 KB (depending on buffers)
  • 10,000 connections: ~20-100 MB
  • CPU: Higher due to connection management

SSE Memory:

  • Each connection: ~1-5 KB
  • 10,000 connections: ~10-50 MB
  • CPU: Lower, simpler HTTP handling

Use Cases and When to Choose

Choosing the right technology depends on your specific requirements.

When to Use WebSockets ✅

Ideal for:

  • Chat applications: Bidirectional messaging required
  • Collaborative editing: Real-time synchronization needed
  • Online games: Low latency, bidirectional communication
  • Trading platforms: High-frequency updates, bidirectional
  • Video/voice calling: WebRTC signaling, bidirectional control

Example Use Case - Chat Application:

// WebSocket is perfect for chat
const ws = new WebSocket("wss://chat.example.com");
// User sends message
function sendMessage(text) {
ws.send(
JSON.stringify({
type: "message",
content: text,
room: currentRoom,
}),
);
}
// Receive messages in real-time
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
displayMessage(message);
};

When to Use Server-Sent Events ✅

Ideal for:

  • Live notifications: One-way server updates
  • Dashboard updates: Real-time metrics and charts
  • Live feeds: News, social media feeds, stock prices
  • Progress updates: Long-running task progress
  • Event streams: Log streaming, audit trails

Example Use Case - Live Dashboard:

// SSE is perfect for one-way updates
const eventSource = new EventSource("/api/metrics");
eventSource.onmessage = (event) => {
const metrics = JSON.parse(event.data);
updateChart(metrics.cpu);
updateChart(metrics.memory);
updateChart(metrics.network);
};
// User actions use regular HTTP requests
async function refreshData() {
const response = await fetch("/api/refresh", { method: "POST" });
// SSE will push the updated metrics
}

When to Use HTTP/2 Push ✅

Ideal for:

  • Initial page loads: Preloading critical CSS/JS
  • Resource optimization: Reducing round trips
  • CDN integration: Pushing assets from edge servers

Note: ⚠️ HTTP/2 Push is deprecated in HTTP/3. Consider alternatives like <link rel="preload"> or Early Hints instead.

Decision Matrix

RequirementWebSocketsSSEHTTP/2 Push
Bidirectional communication✅ Yes❌ No❌ No
Server-to-client only⚠️ Overkill✅ Perfect❌ Wrong use case
Low latency✅ Best✅ Good❌ N/A
Simple implementation❌ Complex✅ Simple⚠️ Medium
Works through proxies⚠️ May fail✅ Yes✅ Yes
Automatic reconnection❌ Manual✅ Automatic❌ N/A
Resource preloading❌ No❌ No✅ Yes

Implementation Examples

Let’s explore complete implementation examples for common scenarios.

Example 1: Real-time Chat with WebSockets

Client Implementation:

class ChatClient {
constructor(url) {
this.ws = null;
this.url = url;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log("Connected to chat");
this.reconnectAttempts = 0;
this.onConnected();
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
this.ws.onerror = (error) => {
console.error("WebSocket error:", error);
this.onError(error);
};
this.ws.onclose = () => {
console.log("Connection closed");
this.onDisconnected();
this.attemptReconnect();
};
}
sendMessage(text, room) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(
JSON.stringify({
type: "message",
content: text,
room: room,
timestamp: Date.now(),
}),
);
}
}
attemptReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
setTimeout(() => this.connect(), delay);
}
}
handleMessage(message) {
switch (message.type) {
case "message":
this.displayMessage(message);
break;
case "userJoined":
this.addUser(message.user);
break;
case "userLeft":
this.removeUser(message.userId);
break;
case "error":
this.showError(message.message);
break;
}
}
onConnected() {
/* Override in subclass */
}
onDisconnected() {
/* Override in subclass */
}
onError(error) {
/* Override in subclass */
}
displayMessage(message) {
/* Override in subclass */
}
addUser(user) {
/* Override in subclass */
}
removeUser(userId) {
/* Override in subclass */
}
showError(message) {
/* Override in subclass */
}
}

Server Implementation:

const WebSocket = require("ws");
const wss = new WebSocket.Server({ port: 8080 });
const rooms = new Map(); // roomId -> Set of clients
wss.on("connection", (ws, req) => {
let currentRoom = null;
let userId = null;
ws.on("message", (message) => {
try {
const data = JSON.parse(message);
switch (data.type) {
case "join":
userId = data.userId;
currentRoom = data.room;
if (!rooms.has(currentRoom)) {
rooms.set(currentRoom, new Set());
}
rooms.get(currentRoom).add(ws);
// Notify others
broadcastToRoom(
currentRoom,
{
type: "userJoined",
userId: userId,
},
ws,
);
ws.send(
JSON.stringify({
type: "joined",
room: currentRoom,
}),
);
break;
case "message":
if (currentRoom) {
broadcastToRoom(
currentRoom,
{
type: "message",
userId: userId,
content: data.content,
timestamp: Date.now(),
},
ws,
);
}
break;
case "leave":
if (currentRoom && rooms.has(currentRoom)) {
rooms.get(currentRoom).delete(ws);
broadcastToRoom(
currentRoom,
{
type: "userLeft",
userId: userId,
},
ws,
);
currentRoom = null;
}
break;
}
} catch (error) {
ws.send(
JSON.stringify({
type: "error",
message: "Invalid message format",
}),
);
}
});
ws.on("close", () => {
if (currentRoom && rooms.has(currentRoom)) {
rooms.get(currentRoom).delete(ws);
broadcastToRoom(
currentRoom,
{
type: "userLeft",
userId: userId,
},
ws,
);
}
});
});
function broadcastToRoom(roomId, message, excludeWs = null) {
if (!rooms.has(roomId)) return;
const messageStr = JSON.stringify(message);
rooms.get(roomId).forEach((client) => {
if (client !== excludeWs && client.readyState === WebSocket.OPEN) {
client.send(messageStr);
}
});
}

Example 2: Live Notifications with SSE

Client Implementation:

class NotificationService {
constructor() {
this.eventSource = null;
this.listeners = new Map();
}
connect() {
this.eventSource = new EventSource("/api/notifications");
this.eventSource.onopen = () => {
console.log("Notification service connected");
};
this.eventSource.onmessage = (event) => {
const notification = JSON.parse(event.data);
this.handleNotification(notification);
};
// Listen for specific event types
this.eventSource.addEventListener("alert", (event) => {
const alert = JSON.parse(event.data);
this.showAlert(alert);
});
this.eventSource.addEventListener("update", (event) => {
const update = JSON.parse(event.data);
this.handleUpdate(update);
});
this.eventSource.onerror = (error) => {
console.error("SSE error:", error);
// EventSource automatically reconnects
};
}
handleNotification(notification) {
// Show notification UI
this.showNotification(notification);
// Trigger registered listeners
const listeners = this.listeners.get(notification.type) || [];
listeners.forEach((listener) => listener(notification));
}
on(type, callback) {
if (!this.listeners.has(type)) {
this.listeners.set(type, []);
}
this.listeners.get(type).push(callback);
}
disconnect() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
showNotification(notification) {
// Implementation depends on your UI framework
console.log("Notification:", notification);
}
showAlert(alert) {
// Show alert UI
console.log("Alert:", alert);
}
handleUpdate(update) {
// Handle update
console.log("Update:", update);
}
}
// Usage
const notifications = new NotificationService();
notifications.connect();
notifications.on("message", (notification) => {
updateMessageBadge(notification.count);
});

Server Implementation:

const express = require("express");
const app = express();
const notificationClients = new Set();
app.get("/api/notifications", (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
notificationClients.add(res);
// Send initial connection confirmation
res.write(
`data: ${JSON.stringify({
type: "connected",
timestamp: Date.now(),
})}\n\n`,
);
req.on("close", () => {
notificationClients.delete(res);
});
});
// Function to send notification to all clients
function sendNotification(type, data) {
const message = `data: ${JSON.stringify({
type: type,
...data,
timestamp: Date.now(),
})}\n\n`;
notificationClients.forEach((client) => {
try {
client.write(message);
} catch (error) {
notificationClients.delete(client);
}
});
}
// Function to send custom event type
function sendCustomEvent(eventType, data) {
const message = `event: ${eventType}\ndata: ${JSON.stringify({
...data,
timestamp: Date.now(),
})}\n\n`;
notificationClients.forEach((client) => {
try {
client.write(message);
} catch (error) {
notificationClients.delete(client);
}
});
}
// Example: Send notification when user receives a message
app.post("/api/send-notification", (req, res) => {
const { userId, message } = req.body;
sendNotification("message", {
userId: userId,
message: message,
});
res.json({ success: true });
});
// Example: Send alert
function sendAlert(severity, message) {
sendCustomEvent("alert", {
severity: severity,
message: message,
});
}
app.listen(3000);

Example 3: Hybrid Approach

Sometimes you need both WebSockets and SSE:

// Use WebSockets for bidirectional chat
const chatWs = new WebSocket("wss://api.example.com/chat");
// Use SSE for one-way notifications
const notifications = new EventSource("https://api.example.com/notifications");
// Chat messages (bidirectional)
chatWs.onmessage = (event) => {
const message = JSON.parse(event.data);
displayChatMessage(message);
};
function sendChatMessage(text) {
chatWs.send(JSON.stringify({ content: text }));
}
// Notifications (one-way)
notifications.onmessage = (event) => {
const notification = JSON.parse(event.data);
showNotification(notification);
};

Best Practices and Common Pitfalls

Following best practices helps you build reliable, scalable real-time applications.

WebSocket Best Practices ✅

1. Implement Proper Reconnection Logic:

class RobustWebSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.reconnectTimeout = null;
this.reconnectDelay = 1000;
this.maxReconnectDelay = 30000;
this.shouldReconnect = true;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectDelay = 1000; // Reset delay on successful connection
this.onOpen();
};
this.ws.onclose = () => {
this.onClose();
if (this.shouldReconnect) {
this.scheduleReconnect();
}
};
this.ws.onerror = (error) => {
this.onError(error);
};
}
scheduleReconnect() {
if (this.reconnectTimeout) return;
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
console.log(`Reconnecting in ${this.reconnectDelay}ms...`);
this.connect();
this.reconnectDelay = Math.min(
this.reconnectDelay * 2,
this.maxReconnectDelay,
);
}, this.reconnectDelay);
}
disconnect() {
this.shouldReconnect = false;
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
}
if (this.ws) {
this.ws.close();
}
}
onOpen() {
/* Override */
}
onClose() {
/* Override */
}
onError(error) {
/* Override */
}
}

2. Handle Message Queuing:

class WebSocketWithQueue {
constructor(url) {
this.ws = null;
this.messageQueue = [];
this.url = url;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
// Send queued messages
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
this.ws.send(message);
}
};
}
send(data) {
const message = JSON.stringify(data);
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(message);
} else {
// Queue message if not connected
this.messageQueue.push(message);
}
}
}

3. Implement Heartbeat/Ping-Pong:

// Server-side heartbeat
setInterval(() => {
clients.forEach((client) => {
if (client.isAlive === false) {
return client.terminate();
}
client.isAlive = false;
client.ping();
});
}, 30000);
ws.on("pong", () => {
ws.isAlive = true;
});

SSE Best Practices ✅

1. Handle Connection Limits:

// Browsers limit concurrent SSE connections per domain
// Solution: Use a single SSE connection with multiple event types
const eventSource = new EventSource("/api/events");
// Instead of multiple connections, use event types
eventSource.addEventListener("notifications", handleNotifications);
eventSource.addEventListener("updates", handleUpdates);
eventSource.addEventListener("alerts", handleAlerts);

2. Implement Proper Error Handling:

const eventSource = new EventSource("/api/events");
eventSource.onerror = (error) => {
// EventSource automatically reconnects
// But you should handle errors gracefully
if (eventSource.readyState === EventSource.CLOSED) {
console.error("SSE connection closed");
// Implement fallback or user notification
}
};

3. Use Event IDs for Reconnection:

// Server-side: Include event IDs
res.write(`id: ${eventId}\ndata: ${JSON.stringify(data)}\n\n`);
// Client-side: Track last received ID
let lastEventId = null;
eventSource.onmessage = (event) => {
lastEventId = event.lastEventId;
// Process message
};
// On reconnection, EventSource automatically sends Last-Event-ID header

Common Pitfalls ❌

1. Not Handling Disconnections:

// ❌ Bad: No reconnection logic
const ws = new WebSocket("wss://api.example.com");
ws.onclose = () => {
// Connection lost forever
};
// ✅ Good: Implement reconnection
const ws = new WebSocket("wss://api.example.com");
ws.onclose = () => {
setTimeout(() => reconnect(), 1000);
};

2. Sending Messages Before Connection Ready:

// ❌ Bad: May fail if connection not ready
const ws = new WebSocket("wss://api.example.com");
ws.send("Hello"); // May fail
// ✅ Good: Check ready state
if (ws.readyState === WebSocket.OPEN) {
ws.send("Hello");
}

3. Not Cleaning Up Connections:

// ❌ Bad: Memory leaks
function createConnection() {
const ws = new WebSocket("wss://api.example.com");
// Never closed
}
// ✅ Good: Clean up on component unmount
function createConnection() {
const ws = new WebSocket("wss://api.example.com");
return () => {
ws.close();
};
}

4. Ignoring Browser Connection Limits:

// ❌ Bad: Multiple SSE connections
const notifications = new EventSource("/api/notifications");
const updates = new EventSource("/api/updates");
const alerts = new EventSource("/api/alerts");
// May hit browser connection limit (usually 6 per domain)
// ✅ Good: Single connection with event types
const eventSource = new EventSource("/api/events");
eventSource.addEventListener("notifications", handleNotifications);
eventSource.addEventListener("updates", handleUpdates);
eventSource.addEventListener("alerts", handleAlerts);

Security Considerations 🔒

1. Authentication:

// WebSocket authentication
const ws = new WebSocket("wss://api.example.com", {
headers: {
Authorization: `Bearer ${token}`,
},
});
// SSE authentication (via query parameter or cookie)
const eventSource = new EventSource("/api/events?token=" + token);

2. Rate Limiting:

// Server-side rate limiting
const rateLimiter = new Map();
function checkRateLimit(clientId) {
const now = Date.now();
const windowStart = now - 60000; // 1 minute window
if (!rateLimiter.has(clientId)) {
rateLimiter.set(clientId, []);
}
const requests = rateLimiter
.get(clientId)
.filter((time) => time > windowStart);
if (requests.length >= 100) {
return false; // Rate limit exceeded
}
requests.push(now);
rateLimiter.set(clientId, requests);
return true;
}

3. Input Validation:

// Always validate and sanitize input
ws.on("message", (message) => {
try {
const data = JSON.parse(message);
// Validate data structure
if (!data.type || !data.content) {
throw new Error("Invalid message format");
}
// Sanitize content
const sanitized = sanitizeInput(data.content);
// Process message
handleMessage({ ...data, content: sanitized });
} catch (error) {
ws.send(JSON.stringify({ type: "error", message: error.message }));
}
});

Conclusion

Choosing the right real-time communication technology depends on your specific requirements. WebSockets excel at bidirectional communication for applications like chat, gaming, and collaborative tools. Server-Sent Events are perfect for one-way server-to-client updates like notifications, live feeds, and dashboard updates. HTTP/2 Server Push (though deprecated in HTTP/3) was designed for resource preloading, but modern alternatives like <link rel="preload"> are preferred.

Key Takeaways:

  • WebSockets: Use for bidirectional, low-latency communication requiring full-duplex channels
  • Server-Sent Events: Use for simple, one-way server-to-client updates with automatic reconnection
  • HTTP/2 Push: Deprecated in HTTP/3; use modern alternatives like preload hints instead
  • Hybrid approaches: Don’t hesitate to combine technologies when appropriate
  • Always implement: Proper error handling, reconnection logic, and security measures

Next Steps:

  • Explore WebSocket libraries like Socket.io for additional features (rooms, namespaces, fallbacks)
  • Learn about GraphQL Subscriptions for real-time data in GraphQL APIs
  • Consider message brokers like Redis Pub/Sub for scaling real-time applications across servers
  • Review error handling strategies for robust real-time applications
  • Study web performance optimization techniques to minimize latency

Remember, the best technology is the one that fits your use case, team expertise, and scalability requirements. Start simple with SSE if you only need one-way communication, and upgrade to WebSockets when you need bidirectional capabilities. Always test your implementation under realistic load conditions and monitor performance metrics to ensure your real-time communication solution meets your application’s needs.