Why our Pomodoro timer is a state machine on the server
July 20, 2026 · 6 min read
Keeping a countdown in a browser tab is the easy way and the wrong way. Here is how modeling a focus session as a server-side finite state machine makes it survive reloads, sync across devices, and refuse to enter an invalid state.
Most Pomodoro apps keep the countdown in a JavaScript timer inside the browser tab. It works right up until the tab is closed, the laptop sleeps, or you switch to your phone — and then the session is simply gone. We took the opposite approach: a focus session is a finite state machine that lives in the backend domain model. The browser is a thin projection of it.
That one decision buys three properties for free: a session survives reloads, it follows you across devices, and it can never enter a state the business rules forbid.
Two orthogonal dimensions
A session isn't in "one of five states" — it has two independent axes. Its status answers "is the machine still alive?" and its phase answers "what should you be doing right now?".
status: running ⇄ paused phase: focus ──▶ short_break ──▶ focus …
│ │ (every Nth break
├──▶ completed (terminal) └──────────▶ long_break)
└──▶ abandoned (terminal)
Modeling these as two fields instead of one flat enum is what keeps the state space honest: a running session can be in a focus phase or a break phase, but a completed session has no phase to speak of.
Transitions are named domain operations
Every legal move is a named operation on the aggregate, and each maps to exactly one API
endpoint: pause, resume, complete-phase, abandon. Anything not on that list —
resuming a completed session, pausing twice — is rejected by the entity before it ever reaches
the database. The rule lives in the domain, not in a controller if statement.
Timing without running a clock
The server never runs a timer. Each phase stores the milliseconds already banked plus the wall-clock instant the current segment started, and remaining time is derived on demand:
elapsed(now) = accumulatedMs + (running ? now - phaseStartedAt : 0)
remaining(now) = phaseDuration - elapsed(now)
Pausing banks the current segment; resuming opens a new one. The API stays stateless between requests, and the browser can extrapolate the countdown locally between syncs even when its clock disagrees with the server's.
The invariant belongs to the database
"One active session per user" is a business rule, so we don't trust application code to hold it alone. A partial unique index makes the storage engine itself reject a second active session, even under two concurrent requests:
CREATE UNIQUE INDEX idx_pomodoro_sessions_one_active_per_user
ON pomodoro_sessions (user_id)
WHERE status IN ('running', 'paused');
Why this matters beyond timers
Explicit states, named transitions, illegal moves refused at the domain layer, invariants backed by the storage engine — that is exactly how we model order lifecycles, approval workflows, and subscription billing for client systems. A Pomodoro timer is just the smallest honest demo of the archetype: a plain domain entity with a unit test around every transition, a thin HTTP adapter, and a UI that only ever renders server state.
If your team is fighting a tangle of boolean flags where a lifecycle should be, this is the shape of the fix — and the kind of modeling work we do on client engagements.