The easy way to build a race room is to keep everything in one process's memory: the roster, the host, the race state. That works right up until there is a second socket server. Two players in the same room can then land on different processes, and neither process knows the other exists. Typestrix's socket server is built for that case.
A race room looks simple from the outside: a code, a roster, a countdown, and progress bars. Underneath, the rule I settled on is simple too. Redis holds what every process must agree on; each process keeps only what its own sockets need. This post walks through that split, what happens when a race stalls, and how a dropped connection gets back into the room.
What is shared and what is local
Pick a step, or use the arrows, to walk through the diagram.
In Redis (shared): one Pub/Sub channel per room, the roster, the current host and the chat settings. Every key expires a day after its last write, so abandoned rooms clean themselves up.
In each process (local): sockets and the user maps, rate-limit buckets, a room-config cache, per-player race state (startedAt, lastProgress), the race text length, and the deadline timers.
Subscribe once per process
Several local players can be in the same room, but a process only needs one Redis subscription per room. RedisService counts references per channel: the first join sends SUBSCRIBE, later joins only increment, and the last leave sends UNSUBSCRIBE. The count is set before awaiting the subscribe call so two players joining at the same moment do not both subscribe.
When the subscriber connection drops and comes back, the ready handler resubscribes every channel still in the count. That restores delivery from then on. It does not replay anything published while the connection was down; plain Pub/Sub keeps no history.
A race, start to finish
Pick a step, or use the arrows, to walk through the diagram.
Progress is checked, not proven
Every progress update passes a few gates before other players see it: values must be in range, a player's progress can only move forward within a round, updates are rate limited per player, and a finish that arrives faster than any human could type the passage is rejected.
These are plausibility checks on numbers the browser reports. The server never sees keystrokes, so it cannot confirm the passage was really typed. That is the same boundary as saved results: plausible, not proven.
Two clocks for a stalled race
Pick a step, or use the arrows, to walk through the diagram.
The server deadline is a backstop, not a coordinator. Every process that receives RACE_START arms one, so with several processes the room can get more than one RACE_COMPLETE. That is harmless because the client ignores a second end (raceEndedRef), but it does mean there is no single elected owner of a race, and a timer disappears if its process restarts.
When a socket drops
Phones switch networks, laptops sleep, and every deploy of the socket server disconnects everyone at once. A dropped socket should not need a page reload, and a whole room of dropped sockets should not all retry in the same millisecond.
Pick a step, or use the arrows, to walk through the diagram.
Waits grow, and spread out
export function getReconnectDelay(attempt: number, random = Math.random): number {
const exponential = Math.min(1000 * Math.pow(2, attempt), 30000);
return exponential + exponential * 0.3 * random();
}
The wait doubles from one second and stops growing at thirty, then up to 30% of random jitter goes on top. The jitter is the important part. After a deploy, every client disconnects at the same instant; with a fixed delay they would all come back at the same instant too, again and again. Random spread turns that spike into a ramp.
- #11.28s1.3s
- #22.03s3.3s
- #34.67s8.0s
- #48.90s16.9s
- #518.18s35.1s
- #635.63s70.7s
- #731.49s102.2s
- #832.96s135.1s
Rejoining without duplicates
Each attempt fetches a new token, because the token only lives for a few minutes and a reconnect can happen long after the page loaded. The rejoin is tracked per socket object, not per render, so React re-rendering never sends a second join, and the message listener moves to the new socket instead of piling up on dead ones.
On the server, a new socket takes over a user on its first message. A late close from the old socket then finds no owner and does nothing, so it can never evict the new connection.
Reconnecting is not resuming
Getting the socket back puts you back in the room. It does not replay what happened while you were gone: Pub/Sub keeps no history, so a race start or progress updates sent during the gap are simply missed. A resumable race would need a round id and a snapshot to fetch on rejoin. For casual races, rejoining the room and catching the next round has been enough.
The trade-off
The result is a design where Redis carries the facts every process must agree on, and each process keeps only what its own sockets need. It scales out without a coordinator, at the cost of plausibility checks that are local to each process and a race that cannot be replayed after a disconnect.