본문으로 건너뛰기
Previous
Next
WebSocket vs SSE vs Long Polling

WebSocket vs SSE vs Long Polling

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 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.


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)

  1. Client sends GET with Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Version: 13, and Sec-WebSocket-Key (16 random bytes, Base64).
  2. Server replies 101 Switching Protocols with Sec-WebSocket-Accept = Base64( SHA1( key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" ) ).
  3. 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](/en/blog/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

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Choose the right real-time technology for your app. Compares WebSocket, Server-Sent Events (SSE), and long polling — wit… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • [WebSocket Complete Guide | Real-Time· Socket.io](/en/blog/websocket-complete-guide/
  • [HTTP Protocols Complete Guide | HTTP/1.1· HTTP/2](/en/blog/http-protocols-complete-guide/

이 글에서 다루는 키워드 (관련 검색어)

WebSocket, SSE, Real-Time, HTTP, Backend, Frontend 등으로 검색하시면 이 글이 도움이 됩니다.