Skip to content

필사 모드: WebSocket, SSE, and Polling — Choosing a Realtime Transport, and Why Most Cases Do Not Need WebSocket

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

Introduction — when you are handed a realtime requirement

When a request arrives saying "show notifications in realtime," the conversation usually starts with WebSocket. You pick a library, write reconnection logic, change the load balancer configuration, and attach a Redis adapter for horizontal scaling. Yet very often the only data actually exchanged is one kind of notification travelling from server to client.

Three questions have to come first. Does data flow in both directions? How frequently do messages go from client to server? How many seconds of latency are acceptable? The answers determine which technology you need. And a great many of those answers are not WebSocket.

How the four approaches behave and what they cost

ApproachLatencyServer connectionOverhead per messageDirectionReconnectionHTTP infra compatibility
Short polling0 up to the intervalOnly at request timeFull request and response headersOne-wayNot neededComplete
Long pollingNear instantHeld for the whole waitFull headers per message plus a reconnectOne-wayA new request each timeComplete
SSENear instantHeld continuouslyA few bytes of event frameOne-wayAutomaticComplete
WebSocketNear instantHeld continuously2 to 14 bytes of frame headerTwo-wayBuild it yourselfNone after the upgrade

The columns worth studying are the last two. SSE has reconnection in the spec and runs on HTTP infrastructure as-is. WebSocket makes you build both yourself.

Look at the cost of short polling concretely. Ten thousand concurrent users checking every 5 seconds is 2000 requests per second. Most of them are empty responses returning "nothing changed." In exchange the server holds no state at all, caches and load balancers work as usual, and after a failure it simply recovers on the next cycle. For a dashboard where refreshing once every 30 seconds is plenty, this is the right answer. It gets ruled out only because it does not look realtime; technically it is the most robust of the four.

Long polling holds the request open and responds when data appears. Latency almost disappears, but every message requires closing the response and opening a new request, and the full headers travel again. When message frequency is high it is markedly more expensive than SSE. These days its place is roughly that of a fallback for environments where SSE is unavailable.

If it is one-directional, SSE wins

SSE is a single ordinary GET request. The response Content-Type is text/event-stream, and the server keeps pouring text out without closing the connection.

GET /api/notifications/stream HTTP/1.1
Accept: text/event-stream
Cookie: sid=8f3c...
HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-store
X-Accel-Buffering: no

retry: 3000

id: 1042
event: order.updated
data: {"orderId":"ord_8812","status":"SHIPPED"}

: keep-alive

id: 1043
event: order.updated
data: {"orderId":"ord_8813","status":"PAID"}

The client code is short too.

const es = new EventSource('/api/notifications/stream')

es.addEventListener('order.updated', (e) => {
  const payload = JSON.parse(e.data)
  applyOrderUpdate(payload)
})

// if the connection drops, the browser reconnects on its own after the retry interval
es.onerror = () => console.warn('stream interrupted, browser will retry')

What you get here are substantive differences against WebSocket.

Reconnection is free. When the network drops or the server restarts, the browser reattaches automatically according to the retry value. With WebSocket you have to write that logic yourself, and most first implementations have neither backoff nor jitter.

Resumption is in the spec. When the server sends an id field, the browser remembers the value and sends it as a Last-Event-ID header on reconnect. The server only has to resend the events after that point. The problem of notifications disappearing while you were disconnected has a standard solution.

Authentication and middleware keep working. Cookies ride along, the existing authentication middleware makes the pass or fail decision, an access log entry is written, and rate limits apply. WebSocket is not HTTP after the upgrade, so all of that has to be rebuilt on top of the protocol.

It is no accident that LLM response streaming is mostly implemented with SSE. It is a textbook one-directional problem where the server only has to push tokens out.

It is worth knowing SSE's real weaknesses precisely too. The EventSource API does not support custom headers, which means you cannot attach an Authorization header. That is fine with cookie authentication, but if your architecture uses bearer tokens you will be tempted to put the token in the query string. Query strings are written verbatim into access logs and proxy logs, so avoid that. The alternative is to read the stream directly with fetch.

const res = await fetch('/api/notifications/stream', {
  headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
})
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()
// with this approach you have to implement reconnection and Last-Event-ID handling yourself

Headers become free, but you lose the automatic reconnection and resumption the spec was giving you. It is worth choosing if you are prepared to implement those two.

One more thing: SSE only sends text. Sending binary means inflating it with base64, which wastes 33 percent. If large binary payloads are travelling back and forth, SSE is not the right fit.

When you genuinely need WebSocket

The boundary is the frequency of client-to-server messages and the latency requirement.

Cases where the client has to speak several times per second, such as sending typing indicators in a chat; streaming cursor positions and editing operations in a collaborative editor; input synchronization in a multiplayer game; exchanging binary frames. In those places the HTTP overhead attached to each request genuinely becomes a problem, and the benefit of bidirectional framing is clear.

Conversely, if client-to-server events happen less than once per second, just send them with POST. The combination of SSE from server to client and plain POST from client to server is the right answer far more often than people expect. On HTTP/2 or HTTP/3 the requests are multiplexed onto one connection, so connection establishment cost is not repeated either.

There is an easy mistake here too. The judgement "it is chat, so WebSocket" is only half right. Sending a message is not something that happens several times per second, so POST is enough, and only the receiving side needs to be a stream. The moment you decide to add high-frequency signals such as typing indicators or live cursors, WebSocket becomes necessary. In other words, what calls for WebSocket is not the chat domain but the property of high-frequency upstream traffic.

Infrastructure reality — timeouts, buffering, connection limits, sticky sessions

Long-lived connections collide with the equipment in the middle. Most of the problems that stay invisible in development and appear in production live here.

Idle timeouts are the most common. The AWS Application Load Balancer has a default idle timeout of 60 seconds, and the nginx proxy read timeout also defaults to 60 seconds. If no bytes flow for 60 seconds, the connection is cut. The symptom of connections repeatedly dropping during quiet periods with few notifications comes from here.

The fix is a heartbeat. In SSE you periodically send a comment line starting with a colon. The client ignores that line, but the equipment in between concludes that traffic is flowing.

// SSE heartbeat — send at an interval no longer than half the shortest timeout
const heartbeat = setInterval(() => res.write(': keep-alive\n\n'), 25_000)
req.on('close', () => clearInterval(heartbeat))

WebSocket has ping and pong control frames. The server sends a ping, and if no pong arrives within a set time, it treats the connection as dead and cleans it up. Skip that cleanup and the state of connections that are actually gone stays in server memory. Since TCP connections disappearing silently is not rare, the heartbeat serves the purpose of detecting dead connections more than avoiding timeouts.

Buffering catches people out often too. nginx buffers proxied responses by default, so SSE events do not go out immediately but come out in clumps. Attach X-Accel-Buffering: no to the response, or turn off proxy buffering for that location. Compression middleware creates the same problem. Passing through gzip middleware that does not flush per event makes the stream look frozen. It is safer to exclude text/event-stream from compression.

The browser connection limit is the weakness most often cited against SSE. Over HTTP/1.1 a browser opens only about six connections per origin. Open one SSE stream per tab and six tabs fill the limit, and not only the seventh tab but every other request going to that origin queues up behind it. If you do not know the cause, it looks like an extremely strange symptom.

This problem disappears on HTTP/2. Streams are multiplexed over a single TCP connection and the default concurrent stream count is 100 or more. If you are behind a modern CDN or load balancer using TLS, there is a good chance you are already being served over HTTP/2. When you evaluate SSE, just check the actual protocol version first. For reference, WebSocket is not subject to this six-connection limit and uses a separate, much larger one.

Sticky sessions are not a choice but a property. Once a connection is established, it is bound to a specific node for its lifetime. The problem that follows from this is deployment. Take one node down in a rolling deploy and every connection on that node drops at once, and the clients all attempt to reconnect at the same time. Take nodes down in sequence and this wave repeats for the entire duration of the deploy. Mitigations are giving connection draining enough time, adding jitter to client reconnection, and having the server send an increased reconnect delay before it shuts down.

Horizontal scaling needs pub-sub

With two or more nodes you immediately hit a problem. User A is connected to node 1 and user B to node 2, and an event created by A has to reach B. Node 1 does not hold B's socket.

So you need a propagation layer between nodes. Redis pub-sub, NATS, and Kafka are the common choices. Each node subscribes to the relevant channels and pushes received events down the connections attached to it.

What to look at when choosing is the delivery guarantee. Redis pub-sub does not deliver to a node that was not subscribed at publish time. A node that was restarting misses the messages from that interval permanently. If notification loss is unacceptable, use a stored log such as Redis Streams or Kafka, and fill the missing range based on Last-Event-ID when the client reconnects.

You also have to compute the fan-out cost. In a structure where every node receives every event, per-node throughput does not decrease as node count grows; it stays the same. Splitting channels finely so that only interested nodes subscribe is the key to scaling.

Reconnection, heartbeats, and backpressure

A reconnection storm is the signature self-inflicted wound of realtime services. When a deploy or a network failure drops 100,000 connections at once, those 100,000 all try to reattach at once. The server has to handle connection establishment, authentication, and initial state transfer all together, and usually collapses again under that load.

The countermeasure is exponential backoff with jitter on the client side.

let attempt = 0

function scheduleReconnect() {
  const cap = 30_000
  const delay = Math.random() * Math.min(cap, 500 * 2 ** attempt)
  attempt += 1
  setTimeout(connect, delay)
}

function onOpen() {
  attempt = 0 // always reset on success
}

With SSE, the retry field lets the server control this value to a degree. Under overload it can send a large value to slow reconnection down.

Backpressure becomes a problem more quietly. Push tens of events per second at a slow client and the data that could not be sent piles up in server memory. In Node, res.write() returns false when the internal buffer is full, and ignoring that return value while continuing to write makes memory grow without bound. WebSocket libraries let you check the amount of buffered data waiting on the socket.

// apply a policy once the backlog crosses a threshold
if (socket.bufferedAmount > 1_000_000) {
  // 1) drop old deltas and merge into a single latest snapshot, or
  // 2) disconnect this client and let it fetch full state on reconnect
  socket.close(1013, 'client too slow')
}

There are three choices: drop, merge, or disconnect. Unbounded buffering is not among them. In particular, for data such as positions or market prices where only the latest value has meaning, merging the backlog and sending only the final state protects accuracy and resources at the same time.

If you use WebSocket you also have to add rate limiting on upstream messages yourself, because the protection provided by HTTP middleware does not apply after the upgrade. Without a message size cap and a messages-per-second cap, a single client can monopolize a node.

Computing server resources — concurrent connections and memory

Capacity planning is simple multiplication, but it is easy to leave items out.

Each connection carries kernel socket send and receive buffers. In the default configuration those run from a few KB to tens of KB and can be tuned. With TLS, a per-session buffer is added on top, reaching tens of KB per connection in the default configuration. On top of that comes the user information, subscription list, and send queue the application holds per connection.

A realistic figure is 10KB to 50KB per connection. At 100,000 concurrent connections that is 1GB to 5GB, and attempts to fit one million onto a single node mostly fail at this calculation. Adding nodes is almost always the better move.

There are a few more limits besides memory. File descriptor caps have to be raised at both the process and the system level. Ephemeral ports used by a proxy when connecting to a backend run out at roughly the 60,000 mark per destination combination, so you need connection reuse between proxy and backend, or a wider set of backend addresses. The connection tracking table of a NAT gateway becomes a ceiling for the same reason.

The packet cost of heartbeats must not be ignored either. Put a 25-second heartbeat on 100,000 connections and 4000 small packets travel back and forth per second. The bandwidth is negligible, but interrupts and context switches are a real cost, and in a service with hardly any events the heartbeat ends up being most of the traffic. Keep the interval only as short as it needs to be.

Closing — realtime only as much as you need

The order for choosing an approach is this. Is a refresh cycle of tens of seconds acceptable? Then finish with polling. Does data flow only from server to client? Then SSE. Automatic reconnection and resumption are in the spec, and authentication, logging, and rate limiting are exactly what you already use. Does the client have to speak several times per second, or exchange binary? That is when WebSocket applies.

The moment you adopt WebSocket you have to build everything HTTP was giving you, and the bill arrives not when you add the library but around month six of operating it. Reconnection storms, dead connection leaks, propagation between nodes, upstream message abuse, and mass disconnection on deploy come in that order. It is better to pay that cost only when the reason for paying it is clear.

현재 단락 (1/96)

When a request arrives saying "show notifications in realtime," the conversation usually starts with...

작성 글자: 0원문 글자: 13,426작성 단락: 0/96