WebSocket vs SSE vs Long Polling | Real-Time Communication Comparison
이 글의 핵심
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) | No (separate protocol) |
| Load balancer | Simple | Simple | Sticky sessions needed |
| Browser support | Universal | Universal | Universal (IE10+) |
1. 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] })
}
}
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
}
Use when: you need real-time updates but can’t use WebSocket or SSE (corporate firewalls, old proxies).
2. 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)
})
})
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()
})
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()
Use when: notifications, live dashboards, activity feeds, AI response streaming — any one-way server push.
3. 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))
}
}
}
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' })
Use when: chat, collaborative editing, live gaming, trading terminals, collaborative whiteboards — any bidirectional real-time communication.
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 }))
}
}
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
Key Takeaways
- Long polling: maximum compatibility, highest latency, use as last resort
- SSE: simple, HTTP/2-native, perfect for server-push — use for notifications, live feeds, AI streaming
- WebSocket: lowest latency, bidirectional — use for chat, gaming, real-time collaboration
- Scaling: WebSocket needs sticky sessions or Redis pub/sub; SSE scales like regular HTTP
- Auto-reconnect: SSE has it built-in; WebSocket needs custom implementation