As soon as a screen has to update without a page reload, WebSocket enters the conversation. Order tracking, export progress, notifications, an LLM answer appearing token by token: the reflex is to open a bidirectional channel. Yet in all of these cases, the client has nothing to say to the server once the connection is established.
The Server-Sent Events (SSE) pattern meets exactly this need with a plain HTTP response that never ends. The choice goes beyond protocol preference. It determines what you get for free (reconnection, resumption, HTTP authentication) and what you will have to build yourself. Let’s compare the two in Python, with FastAPI and Django, all the way to the problems that only show up in production.
In short:
- SSE: the right default when the server does the talking, with native reconnection and resume.
- WebSocket: when the client emits continuously or the data is binary.
- Polling: when data rarely changes and a delay bothers nobody.
SSE: an HTTP response that stays open
With SSE, the browser sends a regular GET. The server answers with Content-Type: text/event-stream, then writes blocks of text as events occur:
id: 1726394400000-0
event: order.shipped
data: {"order_id": 42, "carrier": "colissimo"}
: ping
id: 1726394460000-0
event: order.delivered
data: {"order_id": 42}A blank line ends an event. A line starting with : is a comment that the client ignores, which makes it an ideal keep-alive. In the browser, the EventSource API takes only a few lines:
const source = new EventSource("/orders/42/events", { withCredentials: true });
source.addEventListener("order.shipped", (event) => {
renderShipment(JSON.parse(event.data));
});The most interesting part is what this code leaves out. If the network drops, the browser reconnects on its own and sends the ID of the last event it received in the Last-Event-ID header. The server can then resume exactly where the stream stopped.
WebSocket: another protocol after the handshake
WebSocket also starts with an HTTP request, but that request asks for a protocol switch (Upgrade: websocket). After the 101 Switching Protocols response, it is no longer HTTP. Client and server exchange text or binary frames, in both directions and at any time.
That freedom is real, but the protocol stops there. There are no message IDs, no resumption, and no automatic reconnection in the browser API. All of that remains to be designed:
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
@app.websocket("/ws/rooms/{room_id}")
async def room(websocket: WebSocket, room_id: int) -> None:
await websocket.accept()
try:
while True:
message = await websocket.receive_json()
await handle_client_message(room_id, message)
except WebSocketDisconnect:
await handle_disconnect(room_id)This handler looks short, but it leaves the real questions open. How does the client report the last message it received after a drop? Who replays what it missed? What happens if it sends a message during reconnection? With WebSocket, your application defines its own protocol on top of the protocol.
SSE vs WebSocket: the comparison
| Criterion | Server-Sent Events | WebSocket |
|---|---|---|
| Direction | Server to client | Bidirectional |
| Data | UTF-8 text | Text and binary |
| Transport | Standard HTTP | Dedicated protocol after Upgrade |
| Reconnection | Built into EventSource | Must be implemented client-side |
| Resumption | Last-Event-ID, if the server handles it | Application protocol to design |
| Authentication | Cookies and HTTP middleware | Handshake, then state you manage yourself |
| Origin protection | CORS applies | Origin check is your responsibility |
| Browser limit | 6 connections per domain on HTTP/1.1 | No comparably low limit |
| Django | Async view under ASGI | Django Channels |
Two rows deserve an explanation.
The connection limit. On HTTP/1.1, browsers cap connections at about six per domain, across all tabs. Six tabs open on your application with one SSE stream each, and no further request goes out. HTTP/2 solves this by multiplexing streams over a single TCP connection. Serving SSE over HTTP/2 therefore becomes a prerequisite as soon as users open several tabs.
The origin. The same-origin policy does not protect a WebSocket handshake. If authentication relies on a cookie, a third-party site can open a connection with the user’s credentials unless the server checks the Origin header. This is Cross-Site WebSocket Hijacking. The check is easy to forget, because nothing breaks during development.
SSE with FastAPI: resuming after a drop
Since version 0.135.0, FastAPI supports SSE natively with EventSourceResponse. It handles the format, sends a ping every 15 seconds when the stream is quiet, and sets the Cache-Control: no-cache and X-Accel-Buffering: no headers.
The real issue lies elsewhere. An SSE response lives in a single worker, while the event is produced anywhere: another worker, a Celery task, another service. You need an intermediary, and it has to keep history to allow resumption. Redis pub/sub broadcasts well but forgets everything: a client disconnected for three seconds loses those three seconds. Redis Streams covers both broadcast and replay with a single mechanism (see the Redis Pub/Sub and Streams comparison):
from collections.abc import AsyncIterable
from typing import Annotated
from fastapi import Depends, FastAPI, Header
from fastapi.sse import EventSourceResponse, ServerSentEvent
from redis.asyncio import Redis
app = FastAPI()
redis = Redis.from_url("redis://localhost:6379", decode_responses=True)
STREAM_ID_PATTERN = r"^\d+-\d+$"
async def latest_id(stream: str) -> str:
entries = await redis.xrevrange(stream, count=1)
return entries[0][0] if entries else "0-0"
@app.get(
"/orders/{order_id}/events",
response_class=EventSourceResponse,
dependencies=[Depends(require_order_access)],
)
async def order_events(
order_id: int,
last_event_id: Annotated[
str | None, Header(pattern=STREAM_ID_PATTERN)
] = None,
) -> AsyncIterable[ServerSentEvent]:
stream = f"orders:{order_id}:events"
cursor = last_event_id or await latest_id(stream)
while True:
batches = await redis.xread({stream: cursor}, block=5_000, count=100)
for _, messages in batches:
for message_id, fields in messages:
cursor = message_id
yield ServerSentEvent(
raw_data=fields["payload"],
event=fields["type"],
id=message_id,
)Three details separate a demo from a reliable stream.
The SSE ID is the Redis ID. There is no mapping table to maintain: Last-Event-ID directly becomes the XREAD cursor.
The initial cursor is resolved only once with latest_id. Passing $ on each loop looks simpler, but $ means “the latest at call time”: an event published between two XREAD calls would be lost.
raw_data avoids double serialization. The data field of ServerSentEvent always encodes to JSON, and payload is already a JSON string.
On the producer side, retention is bounded:
await redis.xadd(
f"orders:{order_id}:events",
{"type": "order.shipped", "payload": payload_json},
maxlen=1_000,
approximate=True,
)This call can’t go just anywhere. Publishing to Redis in the middle of an SQL transaction risks announcing a shipped order that a rollback then cancels. The Transactional Outbox solves precisely this problem: the event is written in the same transaction as the business state, then a relay sends it to the stream.
SSE with Django: no Channels required
Django has no SSE abstraction, but StreamingHttpResponse accepts an async iterator under ASGI. That is enough. Here, the ping and the headers are up to you:
import re
from django.http import HttpResponseBadRequest, HttpResponseForbidden
from django.http import StreamingHttpResponse
from .models import Order
from .streams import latest_id, redis
STREAM_ID = re.compile(r"\d+-\d+")
async def event_stream(stream: str, cursor: str):
while True:
batches = await redis.xread({stream: cursor}, block=15_000, count=100)
if not batches:
yield ": ping\n\n"
continue
for _, messages in batches:
for message_id, fields in messages:
cursor = message_id
yield (
f"id: {message_id}\n"
f"event: {fields['type']}\n"
f"data: {fields['payload']}\n\n"
)
async def order_events(request, order_id: int):
user = await request.auser()
allowed = await Order.objects.filter(pk=order_id, owner_id=user.pk).aexists()
if not allowed:
return HttpResponseForbidden()
last_event_id = request.headers.get("Last-Event-ID")
if last_event_id and not STREAM_ID.fullmatch(last_event_id):
return HttpResponseBadRequest()
stream = f"orders:{order_id}:events"
cursor = last_event_id or await latest_id(stream)
response = StreamingHttpResponse(
event_stream(stream, cursor),
content_type="text/event-stream",
)
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
return responseThe access check happens in the view, not in the generator, so a real HTTP status can still be returned. Once the first byte has left, it is too late to answer 403.
This view must run under ASGI, with Uvicorn or Daphne for example. Under WSGI, each open stream ties up a worker for its entire lifetime: a handful of clients is enough to block the application. When a client disconnects, Django cancels the coroutine. If you have resources to release, catch asyncio.CancelledError and re-raise it.
The comparison speaks for itself: to push events, one view is enough. WebSocket requires Django Channels, with its routing, consumers and channel layer. That cost is justified when the client really needs to talk, not to display an order status.
What breaks in production
Proxies
Nginx buffers responses by default, and your stream only arrives once the buffer is full. The X-Accel-Buffering: no header disables this, but it is safer to configure the proxy as well. The two protocols have different needs:
location /orders/ {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 1h;
}
location /ws/ {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1h;
}Watch out for compression too: a GZipMiddleware or a CDN compressing the stream can hold events back in its buffers, exactly like proxy buffering.
The 502 that stops everything
EventSource reconnects after a network error, but a status other than 200 closes the connection for good. A 502 returned by the proxy during a deployment is therefore enough to stop the stream with no further attempt. Watch for that state:
source.onerror = () => {
if (source.readyState === EventSource.CLOSED) {
scheduleReconnectWithJitter();
}
};The random delay matters. Without it, every client cut off by a deployment comes back in the same second.
One Redis connection per client
The code above keeps one blocking XREAD per stream, so one Redis connection per connected client. It is readable and correct up to a certain volume. Beyond that, each worker should read the streams with a single shared loop, then dispatch to local clients through bounded asyncio.Queue instances. The bound matters: a slow client must not inflate the worker’s memory. WebSocket has exactly the same fan-out and backpressure problem.
Duplicates and gaps
Resumption is not an absolute guarantee. If the client returns after MAXLEN has evicted its cursor, XREAD continues from the entries still available and the gap goes unnoticed. Stream data is there to catch up after a drop, not to rebuild state: after a long absence, the client reloads a complete view. It is the same separation as in a CQRS read model, where the stream signals a change and the read is the source of truth.
Since the outbox relay is at-least-once, the same event can also be published twice, with two different stream IDs. Include a business identifier in the payload and ignore what has already been processed, following the logic of the idempotent consumer.
Authentication
EventSource allows neither custom headers nor POST. Without a cookie, that leaves a short-lived, single-use ticket in the URL, never the main JWT, because URLs end up in logs. The browser’s WebSocket API has the same limitation on headers.
The other option is to read the stream with fetch. This is how LLM APIs work: a POST that responds with text/event-stream. You gain headers and a request body, and you lose native reconnection. FastAPI accepts EventSourceResponse on any HTTP method, too.
What if the client still needs to send data?
Needing communication in both directions does not by itself justify WebSocket. If the client sends little, SSE downstream and regular POST requests upstream make a very solid combination:
Browser ── POST /orders/42/cancel ──▶ API
Browser ◀── event: order.cancelled ── SSE streamEach action keeps its routing, validation, permissions and HTTP error codes. To make these POST requests safe to retry after a drop, add Idempotency Keys.
SSE or polling?
They are not the same thing. With polling, the client keeps asking whether something has changed. With SSE, it asks once, and the server sends whenever there is something new. Long polling sits in between, and each has its place: the polling, long polling and SSE comparison covers how they work and what they cost.
Note that XREAD with block is itself a form of long polling, but between the worker and Redis. The browser only sees a continuous SSE stream.
SSE or WebSocket: how to choose
Choose SSE when data flows mainly from server to client: notifications, task progress, dashboards, statuses, LLM response streaming. It is the right default, and your existing HTTP infrastructure keeps working.
Choose WebSocket when the client emits continuously or when two-way latency is part of the product: collaborative editing, multiplayer games, shared cursors, chat with typing indicators, or binary data.
Keep polling if data rarely changes and a thirty-second delay bothers nobody. There are no long-lived connections to operate, and HTTP caching remains available.
So the useful question is not “which protocol is more powerful?”, but “who talks, how often, and what happens after a drop?”.
Conclusion
WebSocket is the right tool for a real two-way conversation. For everything else, it makes you pay for reconnection, resumption and security that SSE or HTTP already provide.
Whatever the protocol, the hardest part sits behind it: a bus that broadcasts across workers, a log that allows replay, reliable publishing after commit, and consumers that tolerate duplicates. Once those foundations are in place, the protocol is just a delivery detail, and SSE is very often the simplest one to operate.
