WebSocket vs SSE vs Long Polling
이 글의 핵심
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.
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 }))
}
}
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
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. Choose the right real-time technology for your app.
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
Keywords Covered in This Article (Related Search Terms)
This article covers WebSocket, SSE, Real-Time, HTTP, Backend, Frontend.