Server-Sent Events and WebSocket both keep a connection open so a server can push data the moment it exists, and teams usually pick between them by habit: WebSocket because it sounds more capable, or SSE because the last project used it. The choice is better made by criteria. Which way does data flow, and how often in each direction? Are payloads binary? How do browser clients authenticate? What sits between the client and your server? What happens when the connection drops, and how much does each open connection cost?

This page works through those questions one at a time. It then builds the hybrid that serves most products well (SSE for data going down, ordinary HTTP requests for actions going up), shows what resume looks like on each side, applies the criteria to three real workloads and prices what it costs to change your mind later. If you have not met SSE before, read the introduction to Server-Sent Events first; this page assumes the basics.

Advertisement

Two shapes on the wire

An SSE stream is an ordinary HTTP GET whose response has the content type text/event-stream and never finishes. The body is UTF-8 text made of lines: data: carries the payload, event: names the event type, id: labels the event, retry: suggests a reconnect delay in milliseconds, and a line starting with a colon is a comment that clients ignore. A blank line dispatches the event. The browser's EventSource parses this format, reconnects on its own when the connection drops, and on reconnect sends the last id it saw in a Last-Event-ID request header. Because the stream is just an HTTP response, it shares cookies, CORS rules, logging and middleware with the rest of your API, and over HTTP/2 or HTTP/3 it is one stream among many on a shared connection.

A WebSocket starts as an HTTP/1.1 GET carrying Upgrade: websocket. If the server agrees, it replies 101 Switching Protocols and after that the same TCP connection carries framed messages in both directions, text or binary, plus ping and pong control frames. After the upgrade it is no longer HTTP, so every proxy and load balancer on the path must understand the upgrade and keep a long-lived connection open. RFC 8441 defines how to bootstrap WebSockets over an HTTP/2 stream and RFC 9220 does the same for HTTP/3, but support across servers, proxies and load balancers varies, so most deployments still give each WebSocket its own HTTP/1.1 connection. Check what your own path supports before relying on either.

Two ways to keep a server-to-client channel openSSE plus ordinary requests (one HTTP/2 connection, many streams)BrowserEventSource + fetchApp serverevent log + handlersproxy / LB: plain HTTP, response buffering offstream 1: GET /events, text/event-streamstream 3: POST /actions, 200 OKreconnect: GET /events + Last-Event-IDWebSocket (its own connection after the upgrade)Browsernew WebSocket(url)App serverper-socket stateproxy / LB: must pass Upgrade, long idle timeoutsGET + Upgrade: websocket, then 101frames: text or binary, any timeframes back, plus ping / pongResume: SSE has Last-Event-ID built into the client; WebSocket resume is a protocol you design.Both need a server-side log to replay from; neither replays anything by itself.
SSE rides an ordinary HTTP response next to ordinary requests; WebSocket upgrades its own connection. Neither replays missed messages unless the server keeps a log.

The criteria, one at a time

CriterionSSEWebSocket
DirectionServer to client only. The client acts through separate HTTP requests.Full duplex on one connection.
PayloadUTF-8 text. Binary must be base64-encoded or fetched separately.Text or binary frames.
Browser authEventSource cannot set custom headers: use cookies (withCredentials cross-origin), a short-lived token in the URL, or a fetch-based client.The browser API cannot set custom headers either: use cookies, a one-time ticket in the URL, or authenticate in the first message.
Reconnect and resumeBuilt into EventSource, including Last-Event-ID. The server still has to replay.Nothing built in. You design reconnect, resume and replay.
Connection limitsOver HTTP/1.1 browsers cap connections per origin (commonly six), so many tabs can starve each other. HTTP/2 and HTTP/3 multiplex.One connection per socket; not subject to the HTTP/1.1 per-origin pool in the same way.
ProxiesPlain HTTP, but response buffering and compression can hold events.Needs upgrade support and long idle timeouts on every hop.
BackpressureTCP flow control; a slow client shows up as slow writes on the server.The browser API exposes bufferedAmount on send and nothing on receive.

Read the table as a list of costs rather than features. Direction is the criterion people start with, and it is often the least decisive: most products send far more data down than up, and the up direction is usually discrete actions such as send this message or move this card. Those fit ordinary HTTP requests. What usually decides the question is the rate of client-to-server messages, binary payloads and what your infrastructure tolerates.

Advertisement

The hybrid: SSE down, POST up

For most products the strongest design is SSE for everything the server pushes and ordinary POST requests for everything the client does. Each action gets a normal endpoint with validation, an idempotency key and a status code. The result appears to every interested client, including the sender, as an event on the stream. The server below keeps an ordered log so reconnecting clients can catch up, drops readers that fall too far behind, and sends a comment every fifteen seconds so idle timeouts on the path never fire.

import asyncio, json
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse

app = FastAPI()
log = []            # (id, kind, payload); use Redis Streams or a table in production
subscribers = set()

def publish(kind, payload):
    eid = len(log) + 1
    log.append((eid, kind, payload))
    for q in list(subscribers):
        if q.full():
            subscribers.discard(q)        # slow reader: cut it; it reconnects and replays
        else:
            q.put_nowait((eid, kind, payload))

@app.post("/actions")
async def action(body: dict):
    publish("chat", body)                 # validate, authorise and persist first
    return {"ok": True, "eventId": len(log)}

def frame(eid, kind, payload):
    return f"id: {eid}\nevent: {kind}\ndata: {json.dumps(payload)}\n\n"

@app.get("/events")
async def events(request: Request):
    sent = int(request.headers.get("last-event-id") or 0)
    q = asyncio.Queue(maxsize=256)
    subscribers.add(q)                    # subscribe before replay so nothing falls between
    async def gen():
        nonlocal sent
        try:
            yield "retry: 3000\n\n"
            for eid, kind, payload in list(log):
                if eid > sent:
                    sent = eid
                    yield frame(eid, kind, payload)
            while q in subscribers:
                try:
                    eid, kind, payload = await asyncio.wait_for(q.get(), 15)
                except asyncio.TimeoutError:
                    yield ": keep-alive\n\n"
                    continue
                if eid > sent:            # skip anything the replay already sent
                    sent = eid
                    yield frame(eid, kind, payload)
        finally:
            subscribers.discard(q)
    return StreamingResponse(gen(), media_type="text/event-stream",
                             headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

The client half is short. Returning the event id from the POST gives you read-your-writes: the sender knows which event will reflect its action and can show a pending state until that id arrives on the stream.

const es = new EventSource("/events", { withCredentials: true });
es.addEventListener("chat", (e) => render(JSON.parse(e.data), Number(e.lastEventId)));

async function send(msg) {
  const r = await fetch("/actions", {
    method: "POST", credentials: "include",
    headers: { "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
    body: JSON.stringify(msg),
  });
  if (!r.ok) throw new Error(`send failed: ${r.status}`);
  return (await r.json()).eventId;      // show "sending" until this id is rendered
}

Two limits of EventSource push some teams to a fetch-based SSE reader: it only issues GET, and it cannot send an Authorization header. Reading response.body from fetch and parsing the lines yourself removes both limits, at the price of writing the reconnect and Last-Event-ID handling yourself. The SSE client lifecycle guide covers what the browser does and does not do for you.

Resume on each side

SSE gives you the client half of resume for free and nothing else. The server must keep enough history to replay from any id a client might send, and must decide what to do when the id is older than its retention: send a reset event telling the client to reload a snapshot, then continue. The SSE format itself puts no ordering on ids; the browser just echoes back the last one it saw. Your replay design needs more: ids that increase within the stream, and one shared id sequence when several servers serve the same stream, which in practice means the ids come from the shared log rather than from each server.

With WebSocket you design both halves. The usual protocol puts a sequence number on every server message, has the client remember the highest one it processed, and opens every reconnect with a resume request. The server replays from its log or answers that the gap is too old. Reconnect timing needs exponential backoff with jitter, as in WebSocket reconnection strategies, or a deploy that drops every socket at once becomes a reconnect storm.

let lastSeq = Number(sessionStorage.getItem("lastSeq") || 0);
function connect(attempt = 0) {
  const ws = new WebSocket(`wss://${location.host}/live`);
  ws.onopen = () => { attempt = 0; ws.send(JSON.stringify({ type: "resume", after: lastSeq })); };
  ws.onmessage = (e) => {
    const m = JSON.parse(e.data);
    if (m.type === "reset") { lastSeq = m.seq; return reloadSnapshot(); }
    if (m.seq <= lastSeq) return;                    // duplicate from replay
    apply(m); lastSeq = m.seq; sessionStorage.setItem("lastSeq", lastSeq);
  };
  ws.onclose = () => {
    const delay = Math.min(30000, 500 * 2 ** attempt) * (0.5 + Math.random());
    setTimeout(() => connect(attempt + 1), delay);
  };
}

Backpressure and server cost

Every open stream holds something on the server: a socket, a buffer, and a task, goroutine or thread. With SSE over HTTP/2 the connection is shared with the client's other requests, so a browser with a dashboard open costs one stream rather than one extra TCP connection. A WebSocket costs its own connection plus whatever per-connection state your protocol keeps, such as subscriptions, auth context and the resume cursor.

Neither protocol saves you from slow consumers. If the server writes faster than a client reads, either the per-connection queue grows without bound or the server must drop data, drop the client, or slow the producer. Pick one deliberately. Bounded queues with disconnect-on-overflow are the safe default for SSE because the client will reconnect and replay; for WebSocket the same pattern works if you built resume. The slow consumer guide walks through the options. On the browser side, a WebSocket sender should watch bufferedAmount before sending large or frequent messages, because send() never blocks and never refuses.

Three worked decisions

Streaming model output into a chat interface. Data flows down as many small text chunks; the user sends one prompt per turn. The prompt is a POST and the reply is a stream, so SSE fits exactly, and many LLM APIs stream this way. The request needs a body and an Authorization header, so use a fetch-based reader rather than EventSource.

A collaborative whiteboard. Every participant sends cursor positions and drawing operations many times a second, often as compact binary updates from a CRDT library, and the order of operations between client and server matters. Splitting that into one POST per operation adds request overhead and two channels whose ordering you must reconcile. Choose WebSocket, with resume and backpressure designed in.

An operations dashboard for a large company. Thousands of viewers, most behind corporate proxies, receive metric updates every few seconds and occasionally acknowledge an alert. Upgrades through those proxies are the most likely thing to fail, and the up direction is rare. Choose SSE over HTTP/2 with a POST for acknowledgements, and test through a real corporate network rather than localhost.

Some workloads fit neither. Fast-paced multiplayer games want unreliable, unordered delivery so a lost packet does not delay newer ones; TCP-based SSE and WebSocket both deliver in order and stall behind a loss. Look at WebRTC data channels or WebTransport for those.

What migrating costs, in each direction

Moving from SSE to WebSocket costs more than people expect. You lose automatic reconnect and Last-Event-ID and must rebuild them. Your POST endpoints become message types inside one socket, so per-route middleware, HTTP status codes, rate limits and access logs must be reinvented as protocol features. Authentication moves into the upgrade or the first message, and every proxy and load balancer on the path needs upgrade support and longer idle timeouts.

Moving from WebSocket to SSE means splitting one protocol into downstream event types plus upstream endpoints. You must handle ordering between a POST and the event that reflects it, which the returned event id solves, and binary payloads must be base64-encoded or moved to separate downloads. Whichever way you go, keep the message schema independent of the transport so a move changes framing, not meaning.

Failure modes

  • Buffered SSE. A reverse proxy or compression layer holds events until a buffer fills, so the stream looks dead. Turn buffering off for the stream route, for example with proxy_buffering off in nginx or by sending X-Accel-Buffering: no from the app, then verify from outside your network.
  • Idle timeouts. Load balancers close connections that stay silent too long. Send an SSE comment or a WebSocket ping well inside the shortest timeout on the path; load balancer pitfalls lists the usual suspects.
  • Connection exhaustion over HTTP/1.1. Several tabs each holding an SSE stream use up the per-origin connection limit and ordinary requests queue. Serve over HTTP/2 or share one stream across tabs.
  • Credentials that expire mid-connection. A token valid at connect time can expire hours later. Re-check authorisation periodically on the server and close the stream when it lapses, so the client reconnects with fresh credentials.
  • Half-open WebSockets. A dead network path can leave both ends believing the socket is open. Without application heartbeats, the server keeps state for clients that are gone.
  • Reconnect storms. A deploy drops every connection at once; without jitter, all clients return in the same second.

What to do next

  1. Write down your traffic in each direction: message rate, size and whether any of it is binary.
  2. Trace the network path, including proxies, load balancers and CDNs, and record which ones support upgrades and what their idle timeouts are.
  3. Default to SSE down plus POST up unless the client sends frequent or binary messages, and record the reason for the choice.
  4. Put a sequence id on every event and keep a replay log with an explicit retention window and reset behaviour.
  5. Bound every per-connection queue and decide what happens on overflow.
  6. Test reconnects by killing the server, the proxy and the client network, and confirm nothing is lost or duplicated.
Key takeaway: Choose between SSE and WebSocket by criteria, not habit. SSE is an ordinary HTTP response with reconnect and Last-Event-ID built into the client, so SSE for data going down plus ordinary POST requests for actions going up fits most products and most proxies. WebSocket earns its extra work when clients send frequent or binary messages on one ordered channel. Either way the server needs an ordered replay log, bounded per-connection queues, heartbeats inside the shortest idle timeout and a tested reconnect path.