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.

3 min read

Sabin Shrestha

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

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.

connection_manager.py (naive)
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 connection

Closing 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.

connection_manager.py (fixed)
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.

Tip

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.

WebSocket connection lifecycle, fan-out broadcast patterns, graceful client disconnect handling.