본문으로 건너뛰기 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.


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.


Other articles related to this topic.


Keywords Covered in This Article (Related Search Terms)

This article covers WebSocket, SSE, Real-Time, HTTP, Backend, Frontend.