WebSocket vs SSE vs Long Polling | Real-Time Communication Comparison

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 PollingSSEWebSocket
DirectionServer → ClientServer → ClientBidirectional
ProtocolHTTPHTTPWS (upgraded HTTP)
LatencyHigh (round trip)LowVery low
ComplexityLowLowMedium
Auto-reconnectManualBuilt-inManual
HTTP/2 supportYesYes (multiplexed)No (separate protocol)
Load balancerSimpleSimpleSticky sessions needed
Browser supportUniversalUniversalUniversal (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