WebSocket vs SSE vs Long Polling
Key takeaways
Three patterns dominate real-time web communication — WebSocket for bidirectional, SSE for server-push, and long polling as a fallback. This guide helps you choose the right one and implement it correctly.
The Three Patterns
| Long Polling | SSE | WebSocket | |
|---|---|---|---|
| Direction | Server → Client | Server → Client | Bidirectional |
| Protocol | HTTP | HTTP | WS (upgraded HTTP) |
| Latency | High (round trip) | Low | Very low |
| Complexity | Low | Low | Medium |
| Auto-reconnect | Manual | Built-in | Manual |
| HTTP/2 support | Yes | Yes (multiplexed) | Usually its own TCP connection (RFC 8441 over HTTP/2 exists but is not universal) |
| Load balancer | Simple | Simple | Needs upgrade support and long idle timeouts |
| Browser support | Universal | All modern browsers | Universal (IE10+) |
The row that matters most in practice is not latency but what the infrastructure between you and the client does to long-lived connections. All three patterns keep a request or socket open far longer than a normal API call, and every proxy, load balancer, and CDN on the path has an opinion about that: idle timeouts, response buffering, connection limits. Long polling survives almost anything because each request eventually completes like normal HTTP. SSE is plain HTTP too, but only works if nothing buffers the response. WebSocket needs every hop to understand the Upgrade handshake. A common misconception is that WebSocket requires sticky sessions — a single WebSocket stays on the server that accepted it; stickiness only becomes necessary when a library falls back to HTTP polling (as Socket.IO can) and consecutive requests must reach the same process.
Long Polling
Client makes a request; server holds it open until it has data (or times out). Client immediately re-requests.
Server (Node.js)
const waiters = new Map() // requestId → { res, timeout }
// Client polls this endpoint
app.get('/api/updates', async (req, res) => {
const clientId = req.query.clientId
const lastEventId = parseInt(req.query.lastId ?? '0')
// Check for pending data immediately
const pending = await getPendingEvents(clientId, lastEventId)
if (pending.length > 0) {
return res.json({ events: pending })
}
// No data yet — hold the connection
const timeout = setTimeout(() => {
waiters.delete(clientId)
res.json({ events: [] }) // empty response = timeout, client re-polls
}, 30000)
waiters.set(clientId, { res, timeout })
req.on('close', () => {
clearTimeout(waiters.get(clientId)?.timeout)
waiters.delete(clientId)
})
})
// When new data arrives, push to waiting clients
function publishEvent(clientId, event) {
const waiter = waiters.get(clientId)
if (waiter) {
clearTimeout(waiter.timeout)
waiters.delete(clientId)
waiter.res.json({ events: [event] })
}
}
The important design decision is hidden in getPendingEvents(clientId, lastEventId): events must be stored, not just pushed. Between one response and the next poll there is a gap — the response is in flight, the client is processing it, the next request is being sent — and publishEvent finds no waiter during that gap. If events only lived in the waiters handoff, anything published in the gap would be lost. Persisting events with increasing ids and having the client send lastId makes the gap harmless: the next poll picks them up immediately.
Two other details are easy to miss. Keying waiters by clientId means a user with two tabs open has only one waiter; the second tab’s request silently replaces the first, which then hangs until its timeout. Key by a per-request id or keep a list per client if that matters. And the 30-second timeout must be shorter than every idle timeout on the path — many proxies and load balancers cut idle requests at 60 seconds, and a proxy-generated 504 looks to the client like a server error rather than “no news”.
Client
async function poll(clientId, lastId = 0) {
try {
const res = await fetch(`/api/updates?clientId=${clientId}&lastId=${lastId}`)
const { events } = await res.json()
for (const event of events) {
handleEvent(event)
lastId = event.id
}
} catch (err) {
await new Promise(r => setTimeout(r, 2000)) // wait before retry
}
poll(clientId, lastId) // immediately re-poll
}
The client calls poll() again at the end without awaiting it. That is not unbounded recursion — each call starts after the previous awaits have resolved, so the stack unwinds every time — but it does mean errors thrown outside the try would be unhandled promise rejections. The 2-second wait on error is essential: without it, a server that responds instantly with an error turns every client into a tight request loop. In production, add jitter to that delay so that thousands of clients don’t reconnect in lockstep after an outage.
Use when: you need real-time updates but can’t use WebSocket or SSE (corporate firewalls, old proxies).
Server-Sent Events (SSE)
One persistent HTTP connection from client to server. Server pushes events; client can’t send data back.
Server
app.get('/api/events', (req, res) => {
// Set SSE headers
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 Nginx buffering
// Send initial comment to establish connection
res.write(':connected\n\n')
// Send events
function sendEvent(event, data, id) {
if (id !== undefined) res.write(`id: ${id}\n`)
if (event) res.write(`event: ${event}\n`)
res.write(`data: ${JSON.stringify(data)}\n\n`)
}
sendEvent('connected', { clientId: req.query.clientId })
// Subscribe to data source
const subscription = eventBus.subscribe(req.query.clientId, (data) => {
sendEvent('update', data, data.id)
})
// Heartbeat to keep connection alive (proxies may kill idle connections)
const heartbeat = setInterval(() => res.write(':heartbeat\n\n'), 15000)
// Cleanup on disconnect
req.on('close', () => {
subscription.unsubscribe()
clearInterval(heartbeat)
})
})
The wire format is plain text: each event is a set of field: value lines ending with a blank line (\n\n). data: carries the payload (multiple data: lines are joined with newlines), event: sets the name that addEventListener listens for, id: sets the id the browser will send back as Last-Event-ID when it reconnects, and retry: tells the browser how many milliseconds to wait before reconnecting. Lines starting with : are comments, which is why :heartbeat works as a keep-alive without triggering any client handler. Because the payload must not contain a bare blank line, JSON.stringify is the safe way to send structured data — it never emits raw newlines.
Buffering is what breaks SSE most often. The X-Accel-Buffering: no header tells nginx not to buffer this response, but compression middleware, some CDNs, and serverless platforms can still hold chunks until the response ends, and the symptom is always the same: nothing arrives for a while, then every event at once. If you use Express’s compression() middleware, exclude text/event-stream or call res.flush() after each write. Note also that Connection: keep-alive is an HTTP/1.1 header; it is harmless there, but Node’s http2 module rejects connection-specific headers, so drop it if you serve over HTTP/2 directly.
This handler also ignores the Last-Event-ID request header. On reconnect the browser sends the id of the last event it received; reading req.headers['last-event-id'] and replaying missed events from storage is what turns SSE’s automatic reconnection into actual reliability rather than a silent gap.
SSE for AI streaming responses
// Stream LLM responses with SSE
app.post('/api/chat', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
const stream = await openai.chat.completions.create({
model: 'gpt-4',
messages: req.body.messages,
stream: true,
})
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content
if (content) {
res.write(`data: ${JSON.stringify({ content })}\n\n`)
}
}
res.write('data: [DONE]\n\n')
res.end()
})
This endpoint uses the SSE format but cannot be consumed with EventSource, because EventSource only issues GET requests and cannot send a body. Chat UIs therefore read it with fetch() and a stream reader — const reader = res.body.getReader() — and split the decoded text on \n\n themselves (or use a small parser library). Keep in mind that a network chunk can end in the middle of an event, so the parser must buffer partial lines. The other thing I would add before shipping this is cancellation: if the user closes the tab, req.on('close') fires but the loop keeps pulling tokens from the model — and you keep paying for them. Abort the upstream request when the client disconnects (the OpenAI SDK stream exposes stream.controller.abort()), and handle errors mid-stream by sending an event: error before ending, since the 200 status has already been sent and cannot be changed.
Client
// Native EventSource API
const es = new EventSource('/api/events?clientId=user-123')
es.onopen = () => console.log('Connected')
// Default events
es.onmessage = (e) => {
const data = JSON.parse(e.data)
console.log('Message:', data)
}
// Named events
es.addEventListener('update', (e) => {
const data = JSON.parse(e.data)
updateUI(data)
})
es.onerror = () => {
// EventSource automatically reconnects!
console.log('Reconnecting...')
}
// The browser automatically reconnects using the Last-Event-ID header
// Server should use this to resume from where client left off
// Cleanup
es.close()
EventSource reconnects by itself after a dropped connection, but not after every kind of failure. If the server answers with a non-200 status or a Content-Type other than text/event-stream — a 401 after a session expires, or a 502 from a proxy — the browser gives up permanently and readyState becomes CLOSED (2). The onerror handler above fires in both cases, so check es.readyState there and recreate the EventSource yourself when it is closed. EventSource also cannot set custom headers, so token-based auth has to go through cookies or a query parameter.
The limit that surprises people is per-browser, not per-server. Over HTTP/1.1, browsers allow about six concurrent connections per origin, and each open EventSource holds one. A user who opens the app in seven tabs finds that the seventh tab’s API calls hang — the pool is exhausted by event streams. Serving over HTTP/2 or HTTP/3, where streams are multiplexed on one connection, removes the problem; otherwise share one stream across tabs with a SharedWorker or BroadcastChannel.
Use when: notifications, live dashboards, activity feeds, AI response streaming — any one-way server push.
WebSocket
Full-duplex connection — both sides can send messages at any time.
Server (Node.js)
import { WebSocketServer } from 'ws'
import http from 'http'
const server = http.createServer(app)
const wss = new WebSocketServer({ server })
// Track connections
const clients = new Map() // clientId → ws
wss.on('connection', (ws, req) => {
const clientId = getClientId(req) // from query param or cookie
clients.set(clientId, ws)
console.log(`Client connected: ${clientId}`)
ws.on('message', (data) => {
const message = JSON.parse(data.toString())
handleMessage(clientId, message)
})
ws.on('close', () => {
clients.delete(clientId)
console.log(`Client disconnected: ${clientId}`)
})
ws.on('error', (err) => {
console.error(`WebSocket error for ${clientId}:`, err)
clients.delete(clientId)
})
// Send initial state
ws.send(JSON.stringify({ type: 'CONNECTED', clientId }))
})
// Send to specific client
function sendToClient(clientId, data) {
const ws = clients.get(clientId)
if (ws?.readyState === 1) { // 1 = OPEN
ws.send(JSON.stringify(data))
}
}
// Broadcast to all clients
function broadcast(data, excludeId) {
for (const [id, ws] of clients) {
if (id !== excludeId && ws.readyState === 1) {
ws.send(JSON.stringify(data))
}
}
}
Three production problems are latent in this server. First, JSON.parse(data.toString()) throws on malformed input, and an exception inside a ws event handler is an uncaught exception that crashes the Node process — any client can take down the server by sending {. Wrap parsing in try/catch and close the socket with code 1003 or 1007 on bad input. Second, ws.send never blocks: if a client reads slowly, outgoing data piles up in memory, visible as ws.bufferedAmount. A broadcast loop should skip or drop clients whose buffered amount exceeds a threshold rather than letting one slow mobile connection grow the heap. Third, there is no authentication or origin check. Browsers do not let JavaScript set custom headers on a WebSocket handshake, so authenticate with a cookie or a short-lived token in the URL or first message, and validate the Origin header — cookies are sent automatically, so without an origin check any website can open an authenticated socket on the user’s behalf (cross-site WebSocket hijacking).
Dead connections are the other quiet leak. When a phone loses network, no close frame is sent, and the server may keep the socket in clients for a long time. The standard pattern with ws is a server-side interval that sends ws.ping() and terminates any connection that did not answer the previous ping with a pong.
Client with auto-reconnect
class ReliableWebSocket {
constructor(url) {
this.url = url
this.handlers = new Map()
this.reconnectDelay = 1000
this.connect()
}
connect() {
this.ws = new WebSocket(this.url)
this.ws.onopen = () => {
this.reconnectDelay = 1000 // reset backoff
this.emit('connected')
}
this.ws.onmessage = (e) => {
const data = JSON.parse(e.data)
this.emit(data.type, data)
}
this.ws.onclose = () => {
this.emit('disconnected')
// Exponential backoff reconnect
setTimeout(() => this.connect(), this.reconnectDelay)
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000)
}
this.ws.onerror = (err) => {
console.error('WebSocket error:', err)
}
}
on(event, handler) {
this.handlers.set(event, handler)
return this
}
emit(event, data) {
this.handlers.get(event)?.(data)
}
send(type, data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, ...data }))
}
}
close() {
this.ws.onclose = null // prevent reconnect
this.ws.close()
}
}
// Usage
const ws = new ReliableWebSocket('wss://api.example.com/ws')
ws.on('connected', () => console.log('Connected'))
ws.on('MESSAGE', (data) => displayMessage(data))
ws.on('USER_JOINED', (data) => addUser(data.user))
ws.send('SEND_MESSAGE', { text: 'Hello!', roomId: 'general' })
This wrapper covers the basic reconnect loop, but real clients usually need three more things. Jitter: after a deploy restarts the server, every client’s backoff timer fires at the same moments (1 s, 2 s, 4 s…), and the synchronized reconnect storm can knock the server over again; multiply the delay by a random factor between 0.5 and 1.5. A send queue: as written, send() silently drops messages while disconnected — note that the last line runs before the connection has opened and is lost. Queue messages while not OPEN and flush them in onopen, or return a failure the UI can show. Resynchronization: messages sent by the server while the client was offline are gone, so on reconnect the client should ask for what it missed (by last message id or timestamp) — the WebSocket equivalent of SSE’s Last-Event-ID, which you have to design yourself. Also note that on() stores a single handler per event type; registering a second listener replaces the first.
Use when: chat, collaborative editing, live gaming, trading terminals, collaborative whiteboards — any bidirectional real-time communication.
WebSocket protocol internals (RFC 6455 snapshot)
Frameworks hide most details, but these four ideas explain many production bugs (failed upgrades, idle disconnects, UTF-8 closes).
Upgrade handshake (HTTP → WebSocket)
- Client sends
GETwithUpgrade: websocket,Connection: Upgrade,Sec-WebSocket-Version: 13, andSec-WebSocket-Key(16 random bytes, Base64). - Server replies
101 Switching ProtocolswithSec-WebSocket-Accept = Base64( SHA1( key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" ) ). - The same TCP socket then carries WebSocket frames, not HTTP bodies. Proxies must forward upgrade headers unchanged.
Frame structure (what actually moves on the wire)
Each frame has a small header: FIN (end of message?), opcode (text/binary/control), payload length, and mask bit for client→server frames. Large messages may be fragmented across multiple frames. Client→server payloads are XOR-masked; server→client are not.
Text (opcode 1) vs binary (opcode 2)
- Text — UTF-8 only; invalid sequences can force a close. Natural fit for JSON strings.
- Binary — arbitrary bytes; use for Protobuf, MsgPack, or media chunks. In the browser, read as
ArrayBuffer/Blob.
Heartbeats: Ping/Pong vs app JSON
- Ping (9) / Pong (10) — lightweight control frames to defeat NAT and LB idle timeouts. Align interval with
proxy_read_timeout/ ALB idle settings. - Application heartbeat — e.g.
{"type":"ping"}on the same channel; Socket.io adds its own heartbeat on top.
For a deeper, Node-focused walkthrough (including Socket.io + Redis), see WebSocket complete guide.
Scaling WebSockets
Multiple WebSocket servers → all need to know about all connections
Client A (server 1) sends message to Client B (server 2)
→ Server 1 doesn't have Client B's connection!
Solution: Redis Pub/Sub
import { createClient } from 'redis'
const pub = createClient()
const sub = createClient()
await pub.connect()
await sub.connect()
// Subscribe to messages for connected clients
await sub.subscribe('messages', (message) => {
const { targetClientId, data } = JSON.parse(message)
const ws = localClients.get(targetClientId)
if (ws?.readyState === 1) ws.send(JSON.stringify(data))
})
// Publish message (any server picks it up)
async function sendToClient(targetClientId, data) {
// Try local first
const ws = localClients.get(targetClientId)
if (ws?.readyState === 1) {
ws.send(JSON.stringify(data))
} else {
// Publish to Redis for other servers
await pub.publish('messages', JSON.stringify({ targetClientId, data }))
}
}
Two Redis clients are required because a connection in subscriber mode can only run subscription commands; trying to publish on sub fails. The single messages channel is fine for a handful of servers, but every server receives and parses every message, so traffic grows with servers × messages. Per-user or per-room channels (user:123, room:general) let each server subscribe only to what its local clients need. Redis Pub/Sub is also fire-and-forget: a server that is restarting or briefly disconnected from Redis misses messages permanently. When delivery matters, write messages to durable storage (a database, or Redis Streams with consumer groups) and use Pub/Sub only as the “something changed” signal. The same fan-out design applies to SSE — SSE “scales like regular HTTP” for accepting connections, but pushing an event to a user connected to another instance needs exactly the same broker.
Decision Framework
Need bidirectional communication?
Yes → WebSocket (or Socket.IO for easier API)
No ↓
Need server-to-client streaming?
Yes → SSE
No ↓
Need simple updates and can't use persistent connections?
Yes → Long Polling
No → Regular HTTP (polling on an interval)
Special cases:
AI response streaming → SSE (perfect fit)
Chat app → WebSocket
Live notifications → SSE
Collaborative editing → WebSocket
Dashboard metrics → SSE
Mobile app with unreliable network → WebSocket + reconnect logic
When I have had to choose for a new feature, the deciding question was usually “how often does the client send data?” If the answer is “occasionally, as a normal action”, SSE for the stream plus ordinary POST requests for the actions is simpler to build, debug with browser devtools, cache, authenticate, and put behind existing HTTP infrastructure. WebSocket earns its extra operational cost when client messages are frequent and latency-sensitive — keystrokes in a collaborative editor, game input, cursor positions — so that paying for a request per message would dominate.
Frequently Asked Questions (FAQ)
Q. Why do my WebSocket or SSE connections drop after about a minute of silence?
A. Proxies and load balancers often close connections that carry no traffic for a set idle timeout; for example, nginx’s default proxy_read_timeout is 60 seconds. Sending a periodic heartbeat (WebSocket ping frames or an SSE comment line such as : ping) keeps the connection active, and raising the proxy timeout helps as well. Clients should still reconnect automatically, which EventSource does by default and which WebSocket code has to implement itself.