Building the 1v1 typing race, the first version was the obvious one: a Node server, a Socket.IO room per race, and the server broadcasting progress to everyone in the room.
It worked. Then it broke in a way that was annoying to fix rather than interesting.
The problem was state, not transport
With a broadcast server, the server holds room state and the clients hold a copy of it. Those two copies drift the moment a connection is unreliable.
A racer loses wifi for two seconds. The client reconnects and re-requests the current state. The server sends what it has. But the server's copy was last written when it last received a keystroke — and the keystrokes that happened during the drop were never counted.
The racer's progress went backwards. The opponent's progress did not, because their connection was fine.
Moving the state out of the process
The fix was to stop treating the room as a thing a process happens to own, and start treating it as a thing that owns its own state.
A Cloudflare Durable Object gives each room an identity: env.RACE_ROOM.idFromName(roomId). Every message for a room goes to the same object, and that object is the only writer of the room's state. There is no second copy to drift.
The client-side WebSocket is PartySocket, but it is not the source of truth — it is a transport. If it drops, the client reconnects to the same room object and asks for a snapshot. The object replies with the authoritative state, including the progress that was counted while the client was offline.
What this changes in practice
The room is now a single writer. That means:
- Progress cannot regress, because the only writer never loses a write.
- Two tabs in the same browser do not each think they are the racer, because the room object tracks participants, not sockets.
- Scaling is not a problem to solve. There is one object per room, and rooms are independent, so it scales with the number of races rather than with the traffic through one server.
The Durable Object is also where the race state machine lives — ready-up, the countdown, the finish, and the rematch that reuses the same room with a new passage. That state is not reconstructable from the clients, so it belongs where it cannot be lost.
The part that was easier than expected
Scoring had to agree on both sides. WPM and accuracy are computed from the same shared module on the server and in the browser, so there is one formula and not two implementations that drift.
The scoring rules are small and worth writing down, because "how do you count a word" has more edge cases than it sounds:
- A word is five characters, not a whitespace-delimited token, so the standard is applied to raw character counts.
- Accuracy is
correctChars / totalTyped, clamped to 1, and an empty input is 100% rather than a division by zero. - Progress is
correctChars / passageLength, clamped, so a racer who types past the end does not exceed 100% and win by a few characters.
Those three functions live in one shared file that both the worker and the client import. The client can predict a score locally for the racer whose hands are on the keyboard, and the server recomputes the same value authoritatively. When they disagree, the server is right — but they should never disagree, because it is the same code.
What I would keep
If I rebuilt this, the one thing I would not give up is the single-writer property. Realtime bugs are usually state bugs wearing a transport costume. Choosing a runtime where the state has an obvious owner removed a whole category of bug before I wrote a line of race logic.