The 100 problems, solved / Schedulers, limiters and concurrency / 029 · Design a stopwatch class

Design a stopwatch class

start, pause, resume, reset and lap with a monotonic clock and an explicit state machine.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements and use cases

A user starts the watch, pauses it, resumes it, records laps while it runs, reads the elapsed time at any moment, and resets everything. Each lap shows its split (time since the previous lap) and the total at that moment. Illegal actions, such as pausing a watch that is not running, are rejected clearly rather than silently ignored.

Out of scope: a display loop or UI thread, persistence across app restarts, and countdown mode. The reported interview framed it as "no libraries or platform classes", so the design must not lean on a framework timer.

Constraints: elapsed time must be exact across any number of pause and resume cycles; time paused never counts; the class must be unit-testable without sleeping, which forces the clock to be injected; and the state must be impossible to get wrong, which rules out a pile of booleans.

2. Core entities and relationships

  • Stopwatch: the only mutable object. It holds the state, the accumulated time of finished runs, the start instant of the current run, and the laps.
  • WatchState: IDLE, RUNNING, PAUSED. The transitions are the whole specification.
  • Lap: an immutable record (index, split, total). Once recorded it never changes, so it can be handed to a UI without copying.
  • IllegalTransition: the error raised for an action the current state does not permit; it names both, so a log line is self-explanatory.
  • clock: any zero-argument callable returning seconds; time.monotonic in production, a FakeClock in tests.
ValuesCoreTest seam0..*raisesinjectedLap (frozen)index: intsplit: floattotal: floatWatchStateIDLERUNNINGPAUSEDStopwatch_state: WatchState_accumulated: float_run_started: float | None_laps: list[Lap]start() / pause() / resume()reset()lap() -> Lapelapsed() -> floatIllegalTransitionstate, actionclock: () -> floattime.monotonicFakeClock.advance(s)

The stopwatch composes its laps: they are created by it and cleared by reset. The clock is used, not owned, which is the one seam that makes every test deterministic.

3. Interfaces and key methods

class WatchState(Enum):
    IDLE = auto(); RUNNING = auto(); PAUSED = auto()

@dataclass(frozen=True)
class Lap:
    index: int
    split: float      # time since the previous lap
    total: float      # elapsed at the moment of the lap

class Stopwatch:
    def __init__(self, clock: Callable[[], float] = time.monotonic) -> None: ...
    def start(self) -> None:      """IDLE -> RUNNING; raises if already running."""
    def pause(self) -> None:      """RUNNING -> PAUSED."""
    def resume(self) -> None:     """PAUSED -> RUNNING."""
    def reset(self) -> None:      """any -> IDLE, laps cleared."""
    def lap(self) -> Lap:         """RUNNING only; records and returns a Lap."""
    def elapsed(self) -> float:   """Total running time, correct in every state."""
    def laps(self) -> tuple[Lap, ...]: ...

Invariants: _run_started is not None exactly when the state is RUNNING; elapsed() equals _accumulated plus the current run; laps are strictly increasing in total; reset is allowed from every state and is the only way back to IDLE.

4. Design patterns and the choices behind them

An explicit state machine instead of flags

What varies is which actions are legal. Two booleans, running and paused, allow four combinations of which one is meaningless, and every method has to reason about both. A three-value state enum with a _require guard makes each method a one-line precondition plus its effect. The full State pattern (one class per state) was considered and rejected: with three states and five actions it would triple the code for no change in behaviour; say that out loud rather than reaching for the pattern by reflex (see trade-offs and over-engineering).

Accumulate on pause, measure on read

The tempting design stores start_time and computes now - start_time, then tries to subtract paused intervals. Instead the watch banks time into _accumulated at each pause and keeps only the current run open. Elapsed time is then a single expression that is correct in every state, and pause and resume are O(1) with no list of intervals.

Monotonic clock, injected

Wall-clock time jumps when the device syncs or the user changes the time zone; a stopwatch must use a monotonic source (see time inside objects). Injecting the clock is what allows the demo at the bottom of the code to assert exact numbers; designing for unit tests is the difference between "it looked right" and a proof.

Laps as value objects

A frozen dataclass for Lap means a UI holding the list cannot corrupt the watch, and equality is by value, which makes tests read naturally.

5. Core flow and code

  1. start(): the guard checks IDLE, records _run_started = clock(), moves to RUNNING.
  2. After 2 s the user presses lap: elapsed() reads 2.0, a Lap(1, split 2.0, total 2.0) is appended and _last_lap_total becomes 2.0.
  3. pause() at 3.5 s: 3.5 is banked into _accumulated, the open run is closed, state PAUSED.
  4. Ten seconds pass. elapsed() still returns 3.5 because no run is open.
  5. resume() opens a new run; half a second later elapsed() returns 4.0.
  6. reset() clears everything and returns to IDLE; a following pause() raises IllegalTransition.
import time
from dataclasses import dataclass
from enum import Enum, auto
from typing import Callable

class WatchState(Enum):
    IDLE = auto(); RUNNING = auto(); PAUSED = auto()

class IllegalTransition(Exception):
    def __init__(self, state: WatchState, action: str) -> None:
        super().__init__(f"cannot {action} while {state.name}")

@dataclass(frozen=True)
class Lap:
    index: int
    split: float
    total: float

class Stopwatch:
    """Elapsed time = accumulated time of finished runs + the current run, if any."""

    def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
        self._clock = clock
        self._state = WatchState.IDLE
        self._accumulated = 0.0           # seconds from runs that ended in a pause
        self._run_started: float | None = None
        self._laps: list[Lap] = []
        self._last_lap_total = 0.0

    @property
    def state(self) -> WatchState:
        return self._state

    def _require(self, action: str, *allowed: WatchState) -> None:
        if self._state not in allowed:
            raise IllegalTransition(self._state, action)

    def start(self) -> None:
        self._require("start", WatchState.IDLE)
        self._run_started = self._clock()
        self._state = WatchState.RUNNING

    def pause(self) -> None:
        self._require("pause", WatchState.RUNNING)
        self._accumulated += self._clock() - self._run_started
        self._run_started = None
        self._state = WatchState.PAUSED

    def resume(self) -> None:
        self._require("resume", WatchState.PAUSED)
        self._run_started = self._clock()
        self._state = WatchState.RUNNING

    def reset(self) -> None:
        self._state = WatchState.IDLE
        self._accumulated = 0.0
        self._run_started = None
        self._laps.clear()
        self._last_lap_total = 0.0

    def elapsed(self) -> float:
        running = (self._clock() - self._run_started) if self._run_started is not None else 0.0
        return self._accumulated + running

    def lap(self) -> Lap:
        self._require("lap", WatchState.RUNNING)
        total = self.elapsed()
        lap = Lap(index=len(self._laps) + 1, split=total - self._last_lap_total, total=total)
        self._laps.append(lap)
        self._last_lap_total = total
        return lap

    def laps(self) -> tuple[Lap, ...]:
        return tuple(self._laps)


class FakeClock:
    """Test double: time moves only when the test says so."""
    def __init__(self) -> None:
        self.now = 1000.0
    def __call__(self) -> float:
        return self.now
    def advance(self, seconds: float) -> None:
        self.now += seconds


def _demo() -> None:
    clock = FakeClock()
    sw = Stopwatch(clock)
    sw.start(); clock.advance(2.0)
    first = sw.lap()                    # split 2.0, total 2.0
    clock.advance(1.5); sw.pause()
    clock.advance(10.0)                 # paused time must not count
    sw.resume(); clock.advance(0.5)
    assert abs(sw.elapsed() - 4.0) < 1e-9
    assert first.split == 2.0 and sw.laps()[0].total == 2.0

6. Extensions, concurrency and what interviewers probe

  • A display that ticks: the UI polls elapsed() on its own timer; the watch never pushes, so it stays free of threads and platform classes, which was the point of the reported question.
  • Countdown mode: a subclass is the wrong tool (it changes the meaning of elapsed); a second small class sharing the same clock and state enum keeps both honest.
  • Thread safety: if two threads can press buttons, wrap each public method in one lock; the state guard already makes a lost race visible as IllegalTransition rather than a corrupted total.
  • Precision: time.monotonic is fine for a UI stopwatch; for sub-millisecond work use perf_counter and say why the two differ.
  • Test first: the fake-clock sequence in _demo, then every illegal transition (start twice, pause when idle, lap when paused), then reset from each state.
  • Common mistake: subtracting time.time() values, which breaks on clock adjustments, and storing paused intervals in a list, which makes elapsed O(n) and the pause logic fragile.

02Where this question was reported

  • Medium · 'Design a stopwatch without using any libraries or Android classes' in a round meant to be DSA
  • Medium · Same author's write-up: start/pause/resume/reset, sealed TimerState, monotonic clock