Server-Sent Events Instead of WebSockets for Live Vote Counts
TIL that live vote tallies only need server-to-client push, not a full-duplex protocol — swapping a hand-rolled WebSocket setup for Server-Sent Events dropped the reconnection logic entirely and simplified the reverse proxy config.
The Problem #
Pollarise shows live vote counts on an open poll — when someone votes, everyone else looking at that poll should see the tally move within a second or two, without refreshing. After fixing the duplicate-vote race with a unique index and an atomic $inc (previous TIL), the tally in the database was finally correct. The next problem was getting that correct number to every open browser tab watching the poll.
Context #
Votes are already submitted through a normal POST /polls/{id}/vote endpoint. The only thing missing was a way to push the updated tally back out to everyone else viewing that poll — a one-directional, server-to-client stream, not a two-way channel.
What I Tried #
The first pass used a raw WebSocket connection per poll: the client opened a socket on mount, the FastAPI server kept a set of connected sockets per poll_id, and every successful vote broadcast the new tally to all of them.
connections: dict[str, set[WebSocket]] = defaultdict(set)
@app.websocket("/ws/polls/{poll_id}")
async def poll_socket(websocket: WebSocket, poll_id: str):
await websocket.accept()
connections[poll_id].add(websocket)
try:
while True:
await websocket.receive_text() # never actually sent anything
except WebSocketDisconnect:
connections[poll_id].discard(websocket)What Went Wrong #
The client never sent anything over the socket — votes still went through the REST endpoint — so receive_text() just sat there as a way to detect disconnects, which is already a sign the protocol didn't match the problem.
The real issue showed up on mobile. Switching from WiFi to cellular, or the phone locking, closed the underlying TCP connection without a clean WebSocket close handshake. onclose fired with code 1006 ("abnormal closure") and no error event first, so there was no single place to hook a retry. I ended up writing manual reconnect logic with backoff, plus a "did I miss a vote while disconnected" fetch to resync the tally after reconnecting — solving a problem the browser already solves for a different transport.
Traefik (the reverse proxy in front of the API — see the earlier TIL on that setup) also needed explicit sticky-session-free routing rules for the socket upgrade, since it isn't just a plain HTTP request/response anymore.
The Solution #
Replaced the WebSocket with Server-Sent Events, since the client only ever receives. The server keeps an asyncio.Queue per connected client, grouped by poll:
subscribers: dict[str, set[asyncio.Queue]] = defaultdict(set)
async def publish_tally(poll_id: str, tally: dict) -> None:
for queue in subscribers[poll_id]:
queue.put_nowait(tally)
@app.get("/polls/{poll_id}/stream")
async def stream_tally(poll_id: str, request: Request):
queue: asyncio.Queue = asyncio.Queue()
subscribers[poll_id].add(queue)
async def event_generator():
try:
while True:
if await request.is_disconnected():
break
# Keep-alive comment every 15s so Traefik's idle timeout
# doesn't close a connection that just has nothing to say yet.
try:
tally = await asyncio.wait_for(queue.get(), timeout=15)
yield f"data: {json.dumps(tally)}\n\n"
except asyncio.TimeoutError:
yield ": keep-alive\n\n"
finally:
subscribers[poll_id].discard(queue)
return StreamingResponse(event_generator(), media_type="text/event-stream")publish_tally is called right after the vote handler's atomic $inc succeeds, so subscribers only get a message when the tally actually changed — no polling, no re-reading the DB per connected client.
The client side is a browser built-in, no library:
useEffect(() => {
const source = new EventSource(`/api/polls/${pollId}/stream`);
source.onmessage = (event) => {
setLiveTally(JSON.parse(event.data));
};
// EventSource reconnects on its own on drop; nothing else to wire up.
return () => source.close();
}, [pollId]);Why It Works #
EventSource is built for exactly this shape of problem: a long-lived, server-to-client-only stream over plain HTTP. When the connection drops — WiFi to cellular, phone sleep, proxy hiccup — the browser reconnects automatically and resends the Last-Event-ID header so the server could resume from where it left off, all without application code. Because it's regular HTTP rather than a protocol upgrade, it also passes through Traefik like any other request; the only proxy-specific thing to get right is not letting an idle connection sit long enough to hit a timeout, which the : keep-alive comment line handles — EventSource ignores lines starting with :, so they cost nothing on the client but reset the proxy's idle clock.
The tradeoff is real, not free: EventSource can't set custom request headers, so it can't carry an Authorization bearer token the way a fetch call can. Auth here relies on the session cookie already being sent with the request, which works for Pollarise's cookie-based sessions but wouldn't for a token-header auth scheme without falling back to a signed query-string token instead.
Lessons Learned #
- Check whether the data actually flows both ways before reaching for a WebSocket. A vote submission and a tally broadcast are two different flows; forcing them through one bidirectional socket added a receive loop that did nothing.
- Reconnection-with-backoff is a solved problem at the browser API level for one-directional streams. Writing it by hand for WebSockets was solving something
EventSourcealready solves natively. - A reverse proxy's idle timeout is invisible until a connection needs to sit quietly for longer than that timeout allows — send something, even a no-op comment, on a shorter interval than the timeout.
What I Would Do Differently #
I'd reach for SSE first and only fall back to WebSockets if a feature genuinely needed the client to push arbitrary messages over the same connection (which, for Pollarise, it doesn't — every write already has a natural REST endpoint).
Related Concepts #
WebSocket close codes, HTTP/1.1 chunked transfer encoding (what SSE rides on), Last-Event-ID and stream resumption, reverse proxy idle timeouts, cookie vs. header-based auth for streaming endpoints.
Related content
A Dead WebSocket in a Broadcast List Breaks the Whole Broadcast Loop
TIL that looping over every connected WebSocket client and sending a message throws on the first one that's already gone, which silently drops the update for everyone still connected after it in the list — the fix is a try/except per connection, not around the loop.
Pollarise: A Real-Time Social Polling Platform
Building the frontend for a fast-interaction polling platform from Figma designs, with local-first updates for high-frequency voting.
Live Poll Results Without WebSockets: Server-Sent Events in a Node.js API
TIL that once vote writes were race-free, the results screen still felt dead until a manual refresh — switching from client polling to Server-Sent Events made results update instantly without the bidirectional complexity WebSockets would have added for a channel that only ever sends in one direction.