PubTrivia is two programs. A Next.js app on Vercel does the writes: a host launches a question, a phone submits an answer, a row changes. A long-lived Node process on Railway holds the WebSocket connections and the timers, because a serverless function cannot sit on a fifteen second countdown and then do something.
They talk over an internal HTTP POST with a shared secret, in both directions. The app tells the server what just happened; the server's timer calls back into the app to close a question when the window ends.
The thing I did not appreciate until the second or third deploy is that those two programs are never the same age. Vercel finishes a deploy when it finishes. Railway finishes when it finishes. In between, an app that knows about a new field is posting to a server that has never heard of it, or a server that expects a field is being posted to by an app that does not send it. There is no coordinated release. There is no version negotiation. There are just two boxes, briefly disagreeing.
What that does to the type
Here is most of the payload type, 8 of its 12 fields, which is duplicated line for line in both packages because the server cannot import from the app:
export type NotifyPayload = {
/** question_launched: tells the WS server how long the timer is */
timeLimitSeconds?: number
/** question_launched: the launched_at ISO timestamp */
launchedAt?: string
/** question_launched: the session's advance_mode */
advanceMode?: 'auto' | 'manual'
/** question_launched / question_closed / answer_received: the session question index */
questionIndex?: number
/** question_closed / answer_received: the question's id, so the WS server need not look it up */
questionId?: string
/** player_joined: the participant's ID for reliable lookup */
participantId?: string
/** question_closed: the index of the next question */
nextIndex?: number
isLast?: boolean
}
All 12 properties are optional. Not one of them is required, including the ones that are obviously required for the event to mean anything.
That looks like weak typing, and for a while I treated it as a thing to clean up. It is not. It is the only honest description of the wire, because the sender might be a build from twenty minutes ago.
The rule that makes optionality safe
Optional fields are only survivable if there is a second source for every one of them. There is: both halves talk to the same Postgres database.
So the rule is the message is a hint, the database is the contract. Nothing may travel in the payload that cannot be re-derived from a row. What the payload buys is a saved round trip on a hot path, not information.
Which turns the receiver into a series of two-line fallbacks:
const index = payload?.questionIndex ?? session.currentQuestionIndex;
const launchedAt =
payload?.launchedAt ?? questionStateAt(session.config, index)?.launched_at ?? new Date().toISOString();
const timeLimitSeconds = payload?.timeLimitSeconds ?? question.timeLimitSeconds;
And in the close handler, a read that only happens when it has to:
// Fill in anything an older app version did not send.
const session =
payload?.questionIndex === undefined || !payload.questionId || payload.isLast === undefined
? await loadSession(sessionId)
: null;
That condition is the whole design in one expression. If the sender told us everything, we do zero queries. If it is an older build, or a new event type we have not finished rolling out, we pay for one loadSession and carry on. The degraded path is slower, never broken, and it is exercised in production every time I deploy.
One field in the progress handler has the same shape with a comment that names the cause out loud:
if (questionIndex === undefined || !questionId) {
// A notify from an app version that did not send these.
const session = await loadSession(sessionId);
if (!session) return;
The field that is not allowed to be filled in
There is exactly one exception to "fall back to our own value", and it is about clocks.
// Only ever the app's clock (the one that stamped launchedAt). This
// process's clock is a different domain and would skew every countdown.
...(payload?.serverNow !== undefined ? { serverNow: payload.serverNow } : {}),
Every phone's countdown is computed against launchedAt, which was stamped by the app. If the server substituted its own Date.now() when the field is missing, it would be mixing two machines' clocks into one subtraction, and the error would show up as the timer running fast or slow in the room by however far apart the two hosts' clocks happen to drift. So the field is forwarded when present and the key is omitted entirely when not. A ?? Date.now() here would be the most reasonable looking bug in the file.
That is the general shape of the exception: you may fall back to the database, you may not fall back to a different authority.
Who answers a question the receiver can infer
The payload carries isLast, which the server could work out for itself by comparing an index to a count. It does not have to. The comment in the type says why:
The action that performed the write knows the answer, so it says so rather than leaving the WS server to infer it from a second read.
This is the part of the design I would defend hardest. Inference from two reads is how two processes end up disagreeing: the app wrote the state, the server reads it a moment later, and anything that happened in between changes the conclusion. Whoever did the write has the answer for free. Send it.
And still make it optional, because an older app did not send it, in which case the server falls back to session?.status === 'completed' and is one read slower.
Status codes are about transport only
The other direction has its own version of the same problem, and solves it with one unintuitive line. When the server's timer calls back to close a question:
// 200 even on logical errors (already closed, etc.) so the caller does not retry.
if (!result.success) return NextResponse.json({ ok: false, error: result.error })
"The question was already closed" is not a failure of the request. It is a successful request whose answer is no. If that returned a 4xx or a 5xx, every retrying caller and every platform-level retry would hammer the app trying to re-close a closed question, and the logs would fill with errors that are really just the race resolving correctly. HTTP status describes whether the message arrived. The body describes what happened.
The reverse route has the matching guard, and it is also optional-shaped:
Body: { sessionId: string, questionIndex?: number }
questionIndex is the question the caller's timer was armed for. A close naming any other question is refused, so a timer that fires late cannot close the question that has since been launched. Which is the same idea as isLast: the caller states its assumption, the receiver checks it, and nobody infers.
What I would tell past me
If you split an app across two deploy targets, the boundary between them is a versioned protocol whether you designed one or not, and the version skew is permanent, not a migration you finish.
Three things fall out of that, and they all fit in a sentence each. Make every field optional, and treat the optionality as a feature rather than debt. Make sure every field has a second source, which in practice means the shared database, and never send anything that does not. And when a field is about time or identity rather than convenience, omit it instead of defaulting it, because a wrong value is worse than an absent one.
None of this is visible when it works. The leaderboard moves when the question closes, on a Tuesday, in a pub, during a deploy I pushed from a laptop. That is the entire feature.
Top comments (0)