DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our game clock does not live in the app, because a serverless function cannot hold a timer for fifteen seconds

A pub quiz is a sequence of deadlines. A question opens, a window of a few seconds runs, the window closes whether or not anyone answered, and after a pause the next question opens on its own. Nothing about that is interesting until you ask which process is holding the stopwatch.

For a while, ours was partly inside the Next.js route that closes a question: the route closed it, then slept, then launched the next one. That is a reasonable thing to write and a completely unreasonable thing to rely on, because a serverless function can be frozen the moment it has sent its response. A sleep inside a request handler is not a promise the platform made you. It is a hope. It had a second bug too, which is the sort of thing that happens when a timer lives in the wrong place: it advanced regardless of the session's advance mode, so sessions set to manual advanced on their own.

So the stopwatch moved. The Next.js app still owns the state machine; the long-running WebSocket server owns every timer.

Three timers, and that is the whole game

const questionTimers = new Map<string, IndexedTimer>();
const advanceTimers = new Map<string, IndexedTimer>();
const sessionEndTimers = new Map<string, NodeJS.Timeout>();
Enter fullscreen mode Exit fullscreen mode

One closes the current question when its window ends. One launches the next question after the results pause, in auto mode. One sends the final leaderboard after the last result has had its moment on screen.

The window itself is three numbers rather than one:

export function questionWindowMs(timeLimitSeconds: number): number {
  return (QUESTION_REVEAL_SECONDS + timeLimitSeconds + CLOSE_GRACE_SECONDS) * 1000;
}
Enter fullscreen mode Exit fullscreen mode

A read-in before the clock starts, the answer window, then a second of grace at the buzzer. The grace is there because a phone on pub WiFi is not on the same millisecond as a server in a datacentre, and the alternative to a grace window is telling a player who pressed at 14.9 seconds that they were late. If you want the product-facing version of that arithmetic, it is written out on the question timer page: three seconds to read, five to fifteen on the clock, one second after.

The timers do not do the work. They ask the app to:

export function requestClose(sessionId: string, questionIndex: number): Promise<void> {
  if ((lastClosedIndex.get(sessionId) ?? -1) >= questionIndex) return Promise.resolve();
  return postInternal(
    '/api/internal/close-question',
    { sessionId, questionIndex },
    `close:${sessionId}:${questionIndex}`,
  );
}
Enter fullscreen mode Exit fullscreen mode

Two internal routes, protected by a shared secret, and both naming the question they mean. That last part matters more than it looks: a close request that names question 4 is refused once question 5 is open, so a timer that fires late cannot cut short the question that replaced the one it was armed for.

The bug that only happens on deploy day

Timers in memory are lost when the process restarts. For a web app that is a non-event. For a quiz, it means the question that was open at the moment of the deploy stays open forever: no close, no next question, forty people in a room looking at a countdown that has reached zero and stopped meaning anything.

You cannot fix this by persisting the timers, because the thing that is lost is not the schedule, it is the process. What you can do is notice that the schedule was never the source of truth in the first place. Every deadline is derivable: the database already records when each question was launched, when it closed, and what the session's advance mode is. A timer is a cache of arithmetic over those columns.

So the server re-derives it, every three seconds:

const SWEEP_INTERVAL_MS = 3_000;

export function startSweep(): NodeJS.Timeout {
  const handle = setInterval(() => void sweep(), SWEEP_INTERVAL_MS);
  handle.unref();
  return handle;
}
Enter fullscreen mode Exit fullscreen mode

The sweep loads a row per live session and reconciles. If a question is launched and not closed and no timer is armed for it, arm one for launched_at + window. If a question is closed, the session is in auto mode and there is a next question, arm the advance. If the session is completed and nothing is scheduled to send the final leaderboard, schedule it.

Which means a deploy is now this: the old process clears its timers and closes every socket with code 1012, "service restart". Phones reconnect, as they would for any dropped socket. The new process accepts them, and within three seconds its sweep has re-armed every deadline from the database. The quiz carries on, and the room sees a reconnect spinner rather than a dead clock.

// 1012 "service restart": the client reconnects straight away.
ws.close(1012, 'server_restart');
Enter fullscreen mode Exit fullscreen mode

The guard that is not obvious until it bites

A sweep that reconstructs state from a database row has a hazard a plain timer does not: the row is a snapshot from a moment ago, and in a game where a question can advance in 200 ms, "a moment ago" can mean "one question behind". A naive reconcile reads an old row, sees question 4 open, and arms a close for question 4 while question 5 is live.

Two lines stop it:

// The row was read before any notify that arrived since, so it can be behind
// the timers. Never let it replace a timer for a later question.
if ((questionTimers.get(sessionId)?.index ?? -1) > index) return;
if ((advanceTimers.get(sessionId)?.index ?? -1) > index + 1) return;
Enter fullscreen mode Exit fullscreen mode

The in-memory timers are allowed to be ahead of the database read. The sweep may fill a gap; it may never move the game backwards.

Why the transitions stayed in the app

It would have been tidier to let the WebSocket server do the state transitions itself. It has a database connection. It is already holding the schedule.

It does not, for one reason: the transitions are the part with the hard concurrency. At any moment, four different things can try to close the same question. The timer. The early close when every table has answered. The sweep. The host's own button. Each transition runs in a transaction that locks the session row and checks the state it is moving from, so exactly one of those four wins and the rest are refused without side effects.

// 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 })
Enter fullscreen mode Exit fullscreen mode

That is the sentence that makes the whole arrangement safe. "You lost the race" is a successful outcome of an HTTP request, not a failure to retry. Keeping one implementation of those guards, in the process that owns the schema, was worth more than saving the WebSocket server an HTTP hop.

The failures that remain are all benign and all logged in the same tone:

console.error(`[gameClock] ${key} failed: HTTP ${res.status} (the sweep will retry)`);
Enter fullscreen mode Exit fullscreen mode

Every error path in the clock ends with the sweep. That is the nice consequence of making your timers a cache: there is no retry logic to write, because the next reconcile is three seconds away and it will notice.

Where to look if you want to see it

  • pub-trivia.app/features/question-timer is the window described above, in the language a landlord cares about, including why the ceiling is fifteen seconds and not thirty.
  • pub-trivia.app/features/live-leaderboard is what the close timer causes: standings move when a question closes, which is the whole reason the close has to be reliable rather than best-effort.
  • pub-trivia.app/features/session-history is the other half of the same data. The columns the sweep reads to rebuild its timers are the columns a host reads next Thursday to settle an argument about question fourteen.
  • A free session needs no card: sign up, open the host view and a couple of phones, and the clock you are watching is the one above.

Top comments (0)