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.
Today I learned that broadcasting a live update to a list of WebSocket clients needs error handling inside the loop, per connection — not wrapped around the loop as a whole. On Pollarise, spectators watching a poll (not the voter, who already gets an optimistic local update) get the new tally pushed over a WebSocket. The naive broadcast worked in testing with one browser tab open and broke as soon as a second tab closed mid-session.
async def broadcast(self, poll_id: str, tally: dict):
for ws in self.connections[poll_id]:
await ws.send_json(tally) # throws on the first dead connectionClosing a tab doesn't always tell the server cleanly and immediately — a dropped Wi-Fi connection, a phone locking, a tab killed by the OS all leave a socket the server still thinks is open. The first send_json against one of those raises, and because it's inside a plain for loop with no per-iteration handling, every client later in self.connections[poll_id] never gets the update — the exception unwinds the whole function before the loop reaches them.
async def broadcast(self, poll_id: str, tally: dict):
dead = []
for ws in self.connections[poll_id]:
try:
await ws.send_json(tally)
except Exception:
# Exact exception type varies by ASGI server/transport — what
# matters is that a stale socket write throws, and one client's
# dead connection shouldn't stop the broadcast for the rest.
dead.append(ws)
for ws in dead:
self.connections[poll_id].remove(ws)Why It Works #
Catching per connection means one bad send_json can only ever cost that one client's update — the loop keeps going to the next connection regardless. Collecting dead sockets into a separate list and removing them after the loop (rather than mutating self.connections[poll_id] while iterating it) avoids skipping an entry, which is the classic bug when you .remove() from a list you're actively looping over.
A stale connection isn't a one-time event — the same dead socket sits in the list until something prunes it, so every broadcast after the disconnect pays the same cost until it's removed. Pruning on first failure, rather than waiting for a separate cleanup pass, keeps the list accurate as of the very next broadcast.
Lessons Learned #
A loop that sends to N independent clients needs N independent failure boundaries. Wrapping the whole loop in one try/except is the natural first instinct and the wrong one — it protects the function from crashing, not the other clients from missing the update.
Related Concepts #
WebSocket connection lifecycle, fan-out broadcast patterns, graceful client disconnect handling.
Related content
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.
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.
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.