We run a live pub quiz app. A hundred phones in a room, one WiFi access point behind the bar, and a WebSocket that has to survive all of it. Drops are not an edge case here, they are Tuesday. So the client hook has the usual exponential backoff:
export function computeBackoff(attempt: number): number {
return Math.min(1000 * Math.pow(2, attempt), 30000)
}
Attempt 0 waits a second, attempt 4 waits sixteen, attempt 5 and beyond sit at the thirty second cap. Standard, boring, correct.
Then we started getting reports that felt wrong. A player's WiFi would blip for about two seconds, and their phone would sit on "Reconnecting" for what they described as "ages". The leaderboard had moved on by two questions before they came back.
The shape of the bug
The reconnect was being scheduled from the socket's onclose handler, and onclose needed to know the current status before deciding what to do. If we were already in the error state or already on the polling fallback, we must not start reconnecting again.
Reading current state inside a setState updater is the standard trick for avoiding a stale closure. So the code did the standard trick:
ws.onclose = () => {
setStatus((prev) => {
if (prev === 'error' || prev === 'polling') return prev
scheduleReconnect() // <- the bug
return 'reconnecting'
})
}
That is a side effect inside a state updater, and React is explicit that updaters must be pure. It is allowed to call yours more than once. StrictMode does it deliberately in development. We also have the React Compiler turned on in next.config.mjs, and it optimises on the assumption that updaters are pure.
scheduleReconnect increments an attempt counter. So every extra invocation of the updater advanced the backoff exponent by one, without a single extra reconnect actually being attempted.
One WiFi blip, updater runs twice, attempt counter goes to 2 instead of 1. Three blips over a quiz night and the counter is at 6, which is past the cap. The player waits thirty seconds for a two second outage. The backoff was not measuring failures any more, it was measuring how many times React had felt like re-running a function.
The fix is a ref, not a rewrite
The tempting fix is to hoist scheduleReconnect out of the updater and read state from the closure instead. That reintroduces exactly the stale closure the updater was avoiding.
What we actually did is keep a synchronous mirror of the status in a ref, and set both in one place:
const [status, setStatusState] = useState<WsStatus>('connecting')
const statusRef = useRef<WsStatus>('connecting')
const setStatus = useCallback((next: WsStatus) => {
statusRef.current = next
setStatusState(next)
}, [])
Now onclose reads the ref, which is always current, and acts outside of React's reconciliation entirely:
ws.onclose = (event: CloseEvent) => {
if (!mountedRef.current) return
if (sessionEndedRef.current) return
const prev = statusRef.current
if (prev === 'error' || prev === 'polling') return
setStatus('reconnecting')
scheduleReconnect()
}
The rule that came out of it, and the one I would write on the wall: inside an updater you may read, you may not act. If a decision needs current state and then needs to do something, the reading and the doing belong on opposite sides of the setState call.
The other number in that file
While we were in there we also changed the polling fallback interval, and the reason is worth a paragraph because it is the same class of mistake in a different costume.
After sixty seconds of failed reconnects the hook gives up on the socket and falls back to polling a Server Action for the game state. That interval used to be five seconds. Five seconds per phone sounds modest until you remember every phone in the venue leaves through one WiFi exit IP. A hundred players polling every five seconds is twelve hundred requests a minute from one address, which was four times the global per-IP allowance at the time.
So the failover path 429'd the entire venue at precisely the moment the venue needed it. We moved to ten seconds and raised the global limit with the arithmetic written into the comment, so the next person to change either number has to look at the other one.
const POLL_INTERVAL_MS = 10_000
Ten seconds is worse responsiveness in a mode that is already degraded. That is the right trade. The failure mode of "slightly stale leaderboard" beats the failure mode of "nobody can play".
Go and break it yourself
If you want to watch the recovery path, it is the most visible thing in the app. Read how the live leaderboard updates, then sign up on the free tier at pub-trivia.app, no card needed, start a session, and join it on your phone with the QR code.
Now put the phone into airplane mode for two seconds and turn it back on. You should see "Reconnecting", then the current question, at the position the room has actually reached, not the one you left. The re-auth handshake ends with the server pushing a full state snapshot built from the database, which is why the phone can rejoin a question that started while it was gone.
Leave it in airplane mode for a full minute and you will see the polling fallback take over instead. That path is slower on purpose.
Top comments (0)