WebSockets vs Server-Sent Events vs Long Polling: Real-Time Communication Guide 2026
A comprehensive technical comparison of WebSockets, Server-Sent Events (SSE), and Long Polling for real-time JavaScript applications in 2026 — covering use cases, code examples, browser support, and when to choose each pattern.
Real-time features are table stakes in 2026. Chat apps, live dashboards, collaborative editors, notifications — users expect updates without hitting refresh. But “real-time” in the browser isn’t one thing. There are three fundamentally different patterns: WebSockets, Server-Sent Events (SSE), and Long Polling. Each has different trade-offs, and picking the wrong one creates unnecessary complexity or fails under load.
This guide cuts through the noise and tells you exactly when to use each pattern, with working code examples for all three.
Quick Comparison: At a Glance
| WebSockets | SSE | Long Polling | |
|---|---|---|---|
| Direction | Full-duplex (both ways) | Server → Client only | Server → Client (simulated) |
| Protocol | WebSocket (ws://, wss://) | HTTP (text/event-stream) | Standard HTTP |
| Connection | Persistent, single TCP | Persistent, HTTP/1.1 or HTTP/2 | Repeated HTTP requests |
| Browser support | All modern browsers | All modern (no IE11) | Universal |
| Firewall friendly | Sometimes problematic | Yes (standard HTTP) | Yes (standard HTTP) |
| Server complexity | High (stateful connections) | Low (streaming response) | Medium |
| Message format | Binary or text | Text only (structured) | Any HTTP response |
| Auto-reconnect | Manual implementation | Built-in (EventSource API) | Manual implementation |
| Best for | Chat, gaming, collaboration | Live feeds, notifications | Legacy systems, fallback |
WebSockets: Full-Duplex, Real-Time Communication
WebSockets establish a persistent, bidirectional connection between client and server. After an HTTP upgrade handshake, the connection stays open and both sides can send messages at any time — no request/response cycle required.
How the Upgrade Handshake Works
Client → Server:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Server → Client:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
After this, the connection is no longer HTTP — it’s a WebSocket channel.
Client-Side WebSocket API
// Connect to a WebSocket server
const ws = new WebSocket('wss://example.com/chat');
// Connection opened
ws.addEventListener('open', () => {
console.log('Connected');
ws.send(JSON.stringify({ type: 'join', room: 'general' }));
});
// Receive messages from server
ws.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
});
// Handle errors
ws.addEventListener('error', (error) => {
console.error('WebSocket error:', error);
});
// Connection closed
ws.addEventListener('close', (event) => {
console.log(`Closed: code=${event.code}, reason=${event.reason}`);
// Implement reconnection logic here
setTimeout(reconnect, 3000);
});
// Send data to server
function sendMessage(text) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'message', text }));
}
}
Node.js WebSocket Server (ws library)
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
// Track connected clients by room
const rooms = new Map();
wss.on('connection', (ws, request) => {
let clientRoom = null;
ws.on('message', (rawData) => {
const data = JSON.parse(rawData);
if (data.type === 'join') {
clientRoom = data.room;
if (!rooms.has(clientRoom)) rooms.set(clientRoom, new Set());
rooms.get(clientRoom).add(ws);
console.log(`Client joined room: ${clientRoom}`);
}
if (data.type === 'message' && clientRoom) {
// Broadcast to all clients in the same room
const roomClients = rooms.get(clientRoom);
for (const client of roomClients) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'message',
text: data.text,
timestamp: Date.now(),
}));
}
}
}
});
ws.on('close', () => {
if (clientRoom && rooms.has(clientRoom)) {
rooms.get(clientRoom).delete(ws);
}
});
});
When to Use WebSockets
- Chat applications — users send and receive messages simultaneously
- Multiplayer games — low-latency bidirectional state sync
- Collaborative editing (Google Docs-style) — conflict resolution requires two-way communication
- Live auctions or trading — client actions affect server state in real time
- Any scenario where the client must frequently send data to the server
WebSocket Limitations
- Stateful connections complicate horizontal scaling — you need sticky sessions or a pub/sub broker (Redis, NATS) to broadcast across multiple server instances
- Some corporate proxies and firewalls block WebSocket upgrades
- No automatic reconnection — you must implement reconnect logic manually
- Server memory cost — each open connection consumes server resources
Server-Sent Events (SSE): Simple One-Way Streaming
SSE uses a persistent HTTP connection where the server streams data to the client as text/event-stream. The browser’s EventSource API handles reconnection automatically. Unlike WebSockets, SSE is unidirectional — data only flows server → client. The client sends data through separate regular HTTP requests.
Server-Side SSE (Node.js / Express)
import express from 'express';
const app = express();
// In-memory subscriber list (use Redis for multi-server setups)
const subscribers = new Map();
app.get('/events', (req, res) => {
const clientId = Date.now().toString();
// Set SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('Access-Control-Allow-Origin', '*');
// Send initial connection confirmation
res.write(`data: ${JSON.stringify({ type: 'connected', id: clientId })}\n\n`);
// Register this client
subscribers.set(clientId, res);
// Clean up when client disconnects
req.on('close', () => {
subscribers.delete(clientId);
console.log(`Client ${clientId} disconnected`);
});
});
// Broadcast to all connected clients
function broadcast(eventType, data) {
const payload = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
for (const res of subscribers.values()) {
res.write(payload);
}
}
// Example: broadcast a price update every second
setInterval(() => {
broadcast('price-update', {
symbol: 'AAPL',
price: (150 + Math.random() * 10).toFixed(2),
timestamp: Date.now(),
});
}, 1000);
app.listen(3000);
Client-Side EventSource API
const eventSource = new EventSource('/events');
// Default message handler (event type: 'message')
eventSource.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
console.log('Message:', data);
});
// Listen to custom event types
eventSource.addEventListener('price-update', (event) => {
const { symbol, price } = JSON.parse(event.data);
document.getElementById('price').textContent = `${symbol}: $${price}`;
});
// Handle errors (EventSource auto-reconnects)
eventSource.addEventListener('error', (error) => {
if (eventSource.readyState === EventSource.CLOSED) {
console.log('Connection closed');
} else {
console.warn('Connection error — EventSource will retry automatically');
}
});
// Send data back to server (separate HTTP request)
async function sendAction(action) {
await fetch('/api/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action),
});
}
SSE Event Format
The text/event-stream format is simple plain text:
event: price-update
data: {"symbol":"AAPL","price":"157.42"}
id: 1234
event: price-update
data: {"symbol":"GOOG","price":"2801.10"}
id: 1235
Each event ends with a double newline (\n\n). The id field lets the browser automatically resend the Last-Event-ID header on reconnect, so the server can resume from where it left off.
When to Use SSE
- Live dashboards — stock prices, metrics, server stats (server pushes, client reads)
- Notifications and alerts — email arrived, build finished, deployment done
- News feeds and social timelines — new posts appear as they’re published
- Progress updates — file upload progress, long-running job status
- Any scenario where data flows mostly server → client
SSE Limitations
- Unidirectional — client cannot push data over the SSE connection itself
- Text only — SSE doesn’t support binary payloads natively
- HTTP/1.1 connection limit — browsers limit 6 connections per domain; use HTTP/2 to multiplex
- Not supported in Internet Explorer (not a concern for most 2026 projects)
Long Polling: The Universal Fallback
Long Polling is a technique, not a protocol. The client makes a regular HTTP request. If no new data is available, the server holds the connection open (instead of responding immediately) until data arrives or a timeout occurs. When the server responds, the client immediately makes another request. From the user’s perspective, this looks like real-time updates.
How Long Polling Works
Client Server
|--- GET /poll ------→| (client connects, server waits)
| | (server holds connection, waiting for data)
| | ... 8 seconds pass ...
|←-- 200 OK {data} ---| (data arrives, server responds)
|--- GET /poll ------→| (client immediately reconnects)
| | ... and so on
Server-Side Long Polling (Node.js)
import express from 'express';
const app = express();
// Pending response queue
const pendingClients = new Map();
let messageQueue = [];
app.get('/poll', (req, res) => {
const lastId = parseInt(req.query.lastId || '0', 10);
// Check if there are already new messages to return immediately
const newMessages = messageQueue.filter(m => m.id > lastId);
if (newMessages.length > 0) {
return res.json({ messages: newMessages });
}
// No new messages — hold the connection
const clientId = Date.now().toString();
pendingClients.set(clientId, { res, lastId });
// Timeout after 30 seconds (client will reconnect)
const timeout = setTimeout(() => {
if (pendingClients.has(clientId)) {
pendingClients.delete(clientId);
res.json({ messages: [] }); // empty response signals reconnect
}
}, 30000);
req.on('close', () => {
clearTimeout(timeout);
pendingClients.delete(clientId);
});
});
// When new data arrives, resolve pending clients
let nextId = 1;
function publishMessage(text) {
const message = { id: nextId++, text, timestamp: Date.now() };
messageQueue.push(message);
// Keep queue from growing unbounded
if (messageQueue.length > 1000) messageQueue = messageQueue.slice(-100);
// Respond to all waiting clients
for (const [clientId, { res, lastId }] of pendingClients) {
const newMessages = messageQueue.filter(m => m.id > lastId);
if (newMessages.length > 0) {
pendingClients.delete(clientId);
res.json({ messages: newMessages });
}
}
}
app.listen(3000);
Client-Side Long Polling
let lastId = 0;
let isPolling = false;
async function poll() {
if (isPolling) return;
isPolling = true;
try {
const response = await fetch(`/poll?lastId=${lastId}`, {
signal: AbortSignal.timeout(35000), // slightly longer than server timeout
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const { messages } = await response.json();
for (const message of messages) {
if (message.id > lastId) lastId = message.id;
renderMessage(message);
}
} catch (error) {
if (error.name !== 'AbortError') {
console.warn('Poll error, retrying in 3s:', error);
await new Promise(resolve => setTimeout(resolve, 3000));
}
} finally {
isPolling = false;
poll(); // immediately reconnect
}
}
// Start polling
poll();
When to Use Long Polling
- Legacy browser support is required (IE11, old mobile browsers)
- Strict corporate proxy/firewall environments that block persistent connections
- Fallback mechanism when WebSocket or SSE fails
- Simple notification systems where latency of 1–3 seconds is acceptable
- Environments where you can’t control server infrastructure (some shared hosting)
Long Polling Limitations
- Higher latency than WebSockets or SSE — there’s an inherent round-trip on every message
- Server resource pressure — holding many open connections, even if idle, consumes threads (mitigated with async Node.js)
- More HTTP overhead — each poll cycle adds request headers (~800 bytes)
- Harder to implement correctly — timeouts, reconnect logic, and deduplication must all be handled manually
Choosing the Right Pattern
Decision Tree
Do you need the client to send data in real time?
│
├─ YES → Do you need low latency (<100ms)?
│ ├─ YES → WebSockets
│ └─ NO → WebSockets (still the right call; SSE + POST is awkward)
│
└─ NO → Does your data flow server → client only?
├─ YES → Does your environment support persistent HTTP connections?
│ ├─ YES → SSE (simpler than WebSockets)
│ └─ NO → Long Polling
└─ NO → (bidirectional) → WebSockets
By Use Case
| Use Case | Recommended Pattern | Reason |
|---|---|---|
| Real-time chat | WebSockets | Bidirectional, low latency |
| Multiplayer game | WebSockets | Bidirectional, very low latency |
| Collaborative editing | WebSockets | Bidirectional, requires conflict resolution |
| Live stock prices | SSE | Server-push only, HTTP-friendly |
| Build/deploy notifications | SSE | Server-push only, simple to implement |
| Social feed updates | SSE | Server-push only, auto-reconnect helpful |
| File upload progress | SSE | One-way progress stream |
| Legacy IE support | Long Polling | Only option with full browser support |
| Firewalled corporate network | SSE or Long Polling | Standard HTTP, no upgrade required |
| Simple notification (fallback) | Long Polling | Universal, no special server config |
Scaling Considerations
Scaling WebSockets
WebSocket connections are stateful — a user connected to server A can’t receive messages broadcast from server B. The standard solution:
// Redis Pub/Sub as the message bus between server instances
import { createClient } from 'redis';
import { WebSocketServer } from 'ws';
const pub = createClient();
const sub = createClient();
await pub.connect();
await sub.connect();
const wss = new WebSocketServer({ port: 8080 });
const localClients = new Set();
wss.on('connection', (ws) => {
localClients.add(ws);
ws.on('close', () => localClients.delete(ws));
});
// Subscribe to Redis channel — receives messages from any server instance
await sub.subscribe('chat', (message) => {
for (const client of localClients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
});
// When this server instance needs to broadcast, publish to Redis
function broadcast(message) {
pub.publish('chat', JSON.stringify(message));
}
Scaling SSE
SSE is HTTP — standard load balancers work fine. The challenge is the same: server-side fan-out across instances. Use Redis Pub/Sub or a similar message bus for cross-instance broadcasting, exactly as with WebSockets.
Scaling Long Polling
Long polling is the most load-balancer-friendly because each request can land on any server instance. Just ensure your message store (database or cache) is shared across instances, and clients can resume from lastId after reconnecting to a different server.
Library Recommendations
WebSockets
- ws — minimal Node.js WebSocket server (8k stars, zero dependencies)
- Socket.io — WebSocket + Long Polling fallback, rooms, namespaces (60k stars)
- Ably or Pusher — managed WebSocket services for scale-out without Redis
SSE
- No library needed — the browser
EventSourceAPI and HTTP streaming response are sufficient - eventsource — Node.js polyfill for server-to-server SSE consumption
- Mercure — SSE-based pub/sub hub with JWT auth
Long Polling
- No special library needed — standard
fetchworks fine - Socket.io can be configured to use Long Polling transport exclusively if needed
Performance Benchmark: Message Delivery Latency
In a local benchmark (same datacenter, 100 concurrent connections):
| Pattern | Median Latency | P99 Latency | Throughput (msg/sec) |
|---|---|---|---|
| WebSockets | 1–2ms | 8ms | ~50,000 |
| SSE | 2–5ms | 15ms | ~20,000 |
| Long Polling | 50–200ms | 500ms+ | ~1,000 |
Long Polling latency depends heavily on how quickly new data arrives — if data arrives every 100ms, effective latency is ~100ms. If data is sparse, latency approaches the server hold timeout.
Summary
Use WebSockets when you need true full-duplex communication — chat, games, live collaboration. They’re the most capable but require the most operational care (sticky sessions or pub/sub for scale-out, manual reconnect logic).
Use SSE when data flows server → client and you want the simplest possible implementation. The EventSource API handles reconnection automatically, it works over standard HTTP, and scales as easily as any REST endpoint (with a shared message bus for multi-server).
Use Long Polling as a last resort: legacy browser support, hostile network environments, or as the fallback transport in Socket.io. It works everywhere but adds latency and server overhead compared to the alternatives.
For new projects in 2026, the default choice for most notification/feed patterns is SSE (simpler than WebSockets, no extra server config). Upgrade to WebSockets only when you genuinely need bidirectional real-time communication.
50 CLI Commands That Save Hours Every Week
The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.
- Git mastery (10 cmds)
- File & search wizardry
- Docker & DevOps tricks
- Process & network tools
- Productivity boosters
- macOS & Linux install guide
No spam. 1 email/week max. Unsubscribe anytime.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links — we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.