PubTrivia runs a quiz across a room full of phones. The Next.js app does the writes; a small WebSocket server on a different host does the fan-out. The only thing a phone can do over that socket, from the server's point of view, is arrive and ask to be caught up.
The arrival is one function. It is about a hundred lines and almost all of it is refusal, which is the normal shape for this kind of code and is not usually written down anywhere. So here it is.
The deadline comes first
const AUTH_TIMEOUT_MS = 5_000;
const timeout = setTimeout(() => {
sendAndClose(ws, 'auth_timeout', 'No auth message received within 5 seconds');
resolve(null);
}, AUTH_TIMEOUT_MS);
A connected socket that has not identified itself is a resource with no owner. Nothing can be sent to it, nothing can be cleaned up on its behalf, and nothing will ever close it, because the half of a TCP connection that goes quiet does not announce itself. Five seconds is long enough for a phone on bad pub WiFi to finish its first round trip and short enough that a scanner holding sockets open gets nowhere.
The timer is cleared on the first message, whatever that message is. A client that sends garbage has not failed to authenticate in time, it has failed to authenticate, and those produce different reasons in the log.
Then the shape
One message is accepted, and it is this:
{ type: 'auth', sessionId, role: 'host' | 'player', participantId? }
Four things can be wrong with it, and each is its own early return: the payload is not JSON, the parsed value is not an object with type: 'auth', the sessionId is not a UUID, or the role is not one of two strings.
The UUID check is the one with a story:
// The client may send a placeholder while no real session exists yet.
// Reject it here instead of letting Postgres throw on an invalid uuid.
if (typeof sessionId !== 'string' || !UUID_RE.test(sessionId)) {
sendAndClose(ws, 'invalid_payload', "'sessionId' must be a valid UUID");
return;
}
A malformed UUID handed to Postgres is not an empty result, it is an error from the type parser, which arrives as a 500 and a Sentry event rather than as "no such session". The client genuinely does send a placeholder sometimes, because a host screen mounts before it has created anything. Two codebases, two runtimes, and the same guard had to be written in both.
participantId is the only optional field, and it is quietly downgraded rather than refused:
const player =
role === 'player' && typeof participantId === 'string' && UUID_RE.test(participantId)
? participantId
: null;
A player who sends a valid one gets their own results in their own snapshot. A player who sends nothing, or nonsense, still gets every shared broadcast, which is everything a phone sitting in a waiting room needs. Refusing the connection over a field that only personalises the payload would turn a cosmetic problem into a player who cannot see the quiz.
Then the one database call, which does two jobs
snapshot = await buildSnapshot(sessionId, player);
There is no separate "does this session exist" query. The snapshot the client needs on connect is also the proof that there is something to connect to, so a null return is the session-not-found branch and a non-null return is the first message of the conversation.
That call is also shared. Sixty phones reconnecting in the same second produce one build between them rather than sixty, which matters because that second is usually the second after a deploy or a WiFi blip, which is exactly when the database is least interested in sixty identical queries.
The outcome that is not a refusal
if (snapshot.sessionStatus === 'completed') {
// Hand over the final standings rather than an error.
sendToClient(ws, { type: 'session_ended', payload: { leaderboard: snapshot.leaderboard } });
ws.close(1000, 'session_ended');
return;
}
This is the branch I would have got wrong. A finished session is, mechanically, a connection that cannot be served: there is nothing to subscribe to and no further events will ever arrive. The obvious handling is auth_failed, and the obvious handling is bad, because of who hits it.
The phone that connects to a completed session is somebody who locked their screen during the last question, or whose train of a reconnection ladder finally succeeded forty seconds after the quiz ended. They are not an error. They want to know who won. So the branch sends them the final standings and closes with code 1000, a normal closure, which the client treats as a clean end rather than as a reason to start the backoff ladder again.
That leaves eight ways out of the function: seven refusals and one that answers the question the client was actually asking.
One more exit, which is silent
// The socket may have closed while the snapshot was being built.
if (ws.readyState !== ws.OPEN) {
resolve(null);
return;
}
There is an await in the middle of this function, and a phone can go away during it. Without this check, the client gets registered in the connection store, subscribed to a Redis channel, and sent two messages, all into a socket that is gone, and then the store carries a dead entry until something else notices.
Any handshake with an await in it needs this line. It is the cheapest bug to write and one of the more annoying ones to find, because the symptom is a connection count that is slightly too high on a graph.
After the handshake, there is exactly one verb
const SYNC_MIN_INTERVAL_MS = 2_000;
ws.on('message', (raw) => {
// …
if (msg?.type !== 'sync') return;
const now = Date.now();
if (now - lastSync < SYNC_MIN_INTERVAL_MS) return;
lastSync = now;
// rebuild and send a snapshot
});
That is the complete post-auth protocol. One message type, meaning "I may have missed something, send me the state". Phones send it when they come back from the lock screen, where the socket can look alive while the tab was frozen and broadcasts went nowhere.
Two details:
Anything that is not sync is dropped without a reply. Not an error frame, not a close, nothing. There is no version of the client that sends anything else, so a socket that does is either confused or probing, and neither deserves a response that tells it what else to try.
The two second floor is per socket, kept in a closure, not in Redis. A phone that comes back from the lock screen and immediately fires visibilitychange, pageshow and online within a few milliseconds of each other would otherwise ask for three snapshots, and a snapshot is a database read. The floor has to be in the server, because the client does not have one. Its wake handler is bound to all three events and each of them calls send straight out, with nothing in between. The browser is also the half you do not control: an old tab left open on a phone is running whatever shipped the last time that page was loaded.
The part that is now dead code
The browser hook still contains this:
case 'auth_failed': {
// Older servers report a finished session this way.
const reason = parsed.reason ?? ''
if (reason.includes('session_completed')) { /* treat as a clean end */ }
The server has seven paths that send auth_failed, and session_completed is not the reason on any of them any more. It sends session_ended and closes cleanly instead. The branch is a compatibility shim for a deployment that no longer exists, kept because the two halves of this app ship to two different platforms and are never atomically the same version for the length of a deploy.
The sibling of that branch is still load-bearing, though:
// The close frame can overtake the session_ended message.
if (event.code === 1000 && event.reason === 'session_ended') { … }
Two things sent back to back over a socket that is being closed do not reliably arrive in that order, so the reason string on the close frame carries the same meaning as the message, and whichever lands first wins.
See it from the other side
The feature this handshake exists to deliver is the one where the leaderboard moves the moment a question closes, on the host screen and on every phone at once. The joining flow is what puts a hundred of these sockets on one WiFi network in the first place, and the pub page is the short version of why any of it is shaped this way.
Top comments (0)