A quiz app with a fifteen second countdown looks like a setInterval and a piece of state. It is not. The moment a score depends on that countdown, you have a distributed systems problem wearing a very small hat.
Three separate clocks have to agree about one question on pub-trivia.app:
- The player's browser, which renders the countdown.
- The server action that scores a submitted answer by elapsed time.
- The WebSocket server's
setTimeout, which closes the question whether or not anybody submitted anything.
Any disagreement between them is visible to a room of people who are competing with each other, which is the harshest possible test environment. Here is what it took.
Phone clocks are wrong, routinely
Not "wrong by a few milliseconds". Wrong by minutes. Devices with a manually set time, devices that have not synced since a flight, devices in the wrong timezone with a confused OS.
If the client computes deadline - Date.now() with its own clock against a server-issued deadline, two things happen in the wild:
- Clock ahead of the server: the player sees Time's up the instant a question opens.
- Clock behind the server: the player gets a comfortable twenty-two second window on a fifteen second question.
The second one is worse than it sounds, because the answer is still scored server-side and rejected after the real deadline. The player watched a countdown that said 7 and was told they were too late.
The fix is the oldest trick there is, and it is about thirty lines:
let offsetMs = 0
let synced = false
export function syncServerClock(serverNowMs: number | undefined | null): void {
if (typeof serverNowMs !== 'number' || !Number.isFinite(serverNowMs) || serverNowMs <= 0) {
return
}
offsetMs = serverNowMs - Date.now()
synced = true
}
export function correctedNow(): number {
return Date.now() + offsetMs
}
Every event we push over the WebSocket carries a server timestamp. The client learns the offset from it and then never calls Date.now() directly for anything that matters: countdowns and elapsed times all go through correctedNow().
Three details that are load-bearing:
The guard is not defensive programming, it is the correctness condition. A malformed or missing timestamp must leave the previous offset intact. Assigning NaN to offsetMs poisons every subsequent countdown on that tab for the rest of the session, and there is no way back without a reload.
The untrained state is safe. Before the first sync the offset is 0, which is exactly the behaviour you had before you added any of this. It degrades to the old thing rather than to a broken thing.
It is per tab, on purpose. A module-level singleton in the browser. No storage, no sharing, because an offset learned in a tab that has been asleep for an hour is worth less than one learned ten seconds ago.
The residual error is one-way network latency, tens of milliseconds, plus whatever NTP skew exists between our own servers. Which brings us to the reason that residual is affordable.
The grace period
export const CLOSE_GRACE_SECONDS = 1
The server closes the question one second after the deadline it advertised. A player who taps exactly on the buzzer, on a phone on pub WiFi, needs their submission to survive the trip. Without the grace window you are rejecting people for their latency, which they experience as the app being broken rather than as physics.
A grace period is also what lets you be relaxed about clock sync precision. Tens of milliseconds of residual error disappear inside a one second cushion. Pick the cushion first, then you know how good your sync has to be.
The lead-in
export const QUESTION_REVEAL_SECONDS = 3
The question appears three seconds before the answer window opens. It exists so people can read, but it has a scoring consequence worth stating precisely:
Elapsed time is measured into the answer window, after the reveal lead-in. A player who answers the instant the options appear earns full points.
If you measure from the moment the question was pushed instead, fast readers are rewarded for reading speed and everyone loses points to the animation. Every clock in the system, the client countdown, the server-side scoring, and the auto-close timer, starts after the same lead-in.
Who owns the deadline
The WebSocket server does. When a host launches a question, the WS server arms one timer:
reveal + limit + grace
and when it fires, it calls the app to close the question. Not the host's browser. The host's laptop can sleep, lose WiFi or be closed entirely, and the question still closes, the answers still lock, and the room still moves on.
Two bits of hygiene around that, both learned the hard way:
const questionTimers = new Map<string, ReturnType<typeof setTimeout>>()
const sessionEndTimers = new Map<string, ReturnType<typeof setTimeout>>()
/** Every timer map, so shutdown cannot clear one and forget the other. */
const ALL_TIMER_MAPS = [questionTimers, sessionEndTimers] as const
Only one timer per session may be live, so launching a question cancels the previous one first. Otherwise a host who re-launches gets two timers racing to close the same question, and the earlier one wins. And when there is more than one timer map, enumerate them in a single constant, because a shutdown path that clears one map and forgets the other is a leak nobody will find.
The ceiling on the answer window is a systems constraint
export const MIN_TIME_LIMIT_SECONDS = 5
export const MAX_TIME_LIMIT_SECONDS = 15
The maximum is not only a pacing preference, it is because the WS server arms reveal + limit + grace as a single setTimeout held in memory. The minimum is not a preference at all: a zero or negative value arms a timer that fires immediately and closes the question before anyone can answer.
Those bounds are enforced server-side as well as in the form, because HTML min and max attributes are advisory and a server action is a directly postable endpoint.
Try it
pub-trivia.app/features/question-timer describes what this looks like from the host's side. If you want to see the clock behave, the free tier needs no card: start a session, join from a phone, and for the full experience set the phone's clock five minutes fast before you do. The countdown will still be right.
Top comments (0)