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.

5 min read

Sabin Shrestha

Full-Stack Developer — Next.js, React & React Native

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 EventSource already 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).

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.