ESC

Type to search the knowledge base.

Stopwatch Timer

Machine-code a stopwatch: elapsed from startedAt, pause stores the accumulated time, never increment a counter on an interval tick.

beginner8 min read
  • machine-coding
  • interview
  • react
  • time

The first stopwatch most people write is a lie. They setInterval(() => setMs((m) => m + 10), 10) and call that elapsed time. The tab sleeps, the browser clamps the timer, and after a minute the display is short. A stopwatch timer stores when the run started and how much had already elapsed. The clock is Date.now(). The interval only paints.

Pause is not “stop incrementing.” Pause folds the open interval into accumulated and clears startedAt. Resume writes a new startedAt. Reset zeros both. If you cannot say those three transitions out loud, the CSS digits will not save the round.

Key takeaways

  • While running, elapsed = accumulated + (Date.now() - startedAt). While paused, elapsed is accumulated.
  • The interval (or rAF) rereads Date.now() and formats. It never does count++.
  • Pause adds the open interval into accumulated and sets startedAt to null.
  • Start/stop is a real button, so Space and Enter work. Do not bind Space on window.
  • A live region announces running or paused, not every centisecond.

elapsed from startedAt, pause stores accumulated, never increment a counter

State is two numbers, not a ticking integer:

type StopwatchState = {
  accumulated: number; // ms already banked while paused
  startedAt: number | null; // Date.now() of the current run, or null
};

function elapsed(state: StopwatchState, now = Date.now()) {
  if (state.startedAt == null) return state.accumulated;
  return state.accumulated + (now - state.startedAt);
}

function start(state: StopwatchState, now = Date.now()): StopwatchState {
  if (state.startedAt != null) return state;
  return { ...state, startedAt: now };
}

function pause(state: StopwatchState, now = Date.now()): StopwatchState {
  if (state.startedAt == null) return state;
  return {
    accumulated: state.accumulated + (now - state.startedAt),
    startedAt: null,
  };
}

function reset(): StopwatchState {
  return { accumulated: 0, startedAt: null };
}

startedAt == null means paused (or never started). That is the running flag. You do not need a third isRunning boolean unless you like desyncing it.

Why counting on the interval is wrong: setInterval(fn, 10) is a scheduler, not a clock. Hidden tabs often get a 1000 ms clamp. The callback can be late, skipped, or bunched. Ten late ticks are 100 ms of fiction. Date.now() - startedAt is wall time. After a sleep, the next paint jumps to the truth. Same idea as the analog clock: read Date, do not count.

The display loop is allowed to be coarse. 50 ms or one rAF is enough for hundredths. Alignment to a 10 ms boundary is optional polish.

function formatMs(ms: number) {
  const clamped = Math.max(0, ms);
  const m = Math.floor(clamped / 60000);
  const s = Math.floor((clamped % 60000) / 1000);
  const cs = Math.floor((clamped % 1000) / 10);
  const pad = (n: number, w = 2) => String(n).padStart(w, "0");
  return `${pad(m)}:${pad(s)}.${pad(cs)}`;
}

Keep formatting pure. Tests can pass a frozen now. For a fake clock in unit tests, see implement a fake setTimeout. Do not spin on Date.now() in the test host.

Problem statement

Build a stopwatch: start, pause, resume, reset. Elapsed time is derived from timestamps. Keyboard can start and stop. Screen readers hear running versus paused without a stream of ticks.

Requirements

Must have

  • Visible mm:ss.cc (or mm:ss)
  • Start, pause/resume, reset
  • Elapsed from accumulated + open interval
  • Interval or rAF used only to refresh the label
  • Keyboard start/stop via the toggle button
  • Live region for running / paused / reset

Should have

  • Resume continues from the banked total, not from zero
  • Clear the ticker on pause, unmount, and reset
  • aria-pressed on the toggle, or distinct Start / Pause labels

Nice to have

  • Laps (store elapsed() at click, do not copy the label string as source of truth)
  • Split vs lap
  • Keyboard L for lap while the widget contains focus

Planning (5 minutes out loud)

  1. Model: accumulated and startedAt, derive elapsed
  2. MVP: start, pause, resume, reset, formatted time
  3. Ticker: start interval only when startedAt is set; cleanup in the effect
  4. Then button names and a polite live region
  5. Do not store ms that you increment

Architecture

Stopwatch
├── Time (role="timer", aria-live="off")
├── Status (role="status")
├── Toggle (Start / Pause)
└── Reset

API

type StopwatchProps = {
  onChange?: (ms: number, running: boolean) => void;
};

Implementation sketch

function Stopwatch({ onChange }: StopwatchProps) {
  const [accumulated, setAccumulated] = useState(0);
  const [startedAt, setStartedAt] = useState<number | null>(null);
  const [now, setNow] = useState(() => Date.now());
  const running = startedAt != null;
  const ms = elapsed({ accumulated, startedAt }, running ? now : Date.now());

  useEffect(() => {
    if (startedAt == null) return;
    const id = setInterval(() => setNow(Date.now()), 50);
    return () => clearInterval(id);
  }, [startedAt]);

  useEffect(() => {
    onChange?.(ms, running);
  }, [ms, running, onChange]);

  function toggle() {
    const t = Date.now();
    if (startedAt == null) {
      setNow(t);
      setStartedAt(t);
    } else {
      setAccumulated(accumulated + (t - startedAt));
      setStartedAt(null);
    }
  }

  function onReset() {
    setAccumulated(0);
    setStartedAt(null);
    setNow(Date.now());
  }

  const status = running ? "Running" : ms === 0 ? "Ready" : "Paused";

  return (
    <div>
      <p role="timer" aria-live="off" aria-label={`Elapsed ${formatMs(ms)}`}>
        {formatMs(ms)}
      </p>
      <p role="status" aria-live="polite">
        {status}
      </p>
      <button type="button" onClick={toggle} aria-pressed={running}>
        {running ? "Pause" : "Start"}
      </button>
      <button type="button" onClick={onReset}>
        Reset
      </button>
    </div>
  );
}

The effect depends on startedAt, not on now. If you put now in the deps, you restart the interval every 50 ms and the cadence is junk. That is the same stale-versus-restart trap as useInterval: the timer identity stays stable, the read of Date.now() is fresh.

aria-live="off" on the digits is deliberate. A live region that updates twenty times a second is unusable. Status changes three ways: Ready, Running, Paused. That is what AT should hear.

role="timer" marks the numeric readout. It is a live-region role in ARIA, so keep aria-live="off" (or do not duplicate a polite live on the same node) so the role does not start announcing ticks.

Keyboard start/stop: the toggle is a <button>. Space and Enter already activate it when focused. A global Space handler steals Space from inputs and from the Reset button. If they insist on a shortcut while the widget is focused, listen on the container and preventDefault only when the target is not another button. Prefer the labeled control.

React Strict Mode will start and stop the interval twice in development. Cleanup must clearInterval. That is expected, not a bug in the model.

Accessibility essentials

Piece Approach
Digits Text in the tree, role="timer", aria-live="off"
State role="status" for Running / Paused / Ready
Toggle <button> with Start or Pause text, optional aria-pressed
Reset Separate button, never a clickable heading
Keys Native button activation. No window-level Space
Time Accessible name includes “Elapsed” plus the formatted value

Do not put the time only in a canvas or a CSS content string. If images of digits are a stretch goal, they still need the text alternative.

Interview scoring

Signal What they want
Model elapsed = accumulated + (now - startedAt) said out loud
Pause Bank into accumulated, clear startedAt
Ticker Display only; survives tab sleep
Cleanup clearInterval on pause and unmount
A11y Buttons, status live region, no tick spam
Resume Continues, does not jump to zero

They may background the tab or ask “what if the interval is late?” If you counted, you fail that question. If you read the clock, you pass it.

Footguns

  1. setMs((m) => m + 10) on the interval. This is the fail they are fishing for.
  2. isRunning plus startedAt that disagree after reset.
  3. Leaving the interval on while paused, wasting wakes and risking a stray tick that mutates state.
  4. Announcing every tick in a live region.
  5. Pause that sets a flag but leaves startedAt set, so the next paint includes time spent paused.
  6. Date.now() in render with no ticker while running: some screens never refresh; others refresh too often if a parent re-renders. Drive now from the interval.

performance.now() is fine too. It is monotonic and not wall-clock. For a stopwatch, either works. Date.parse and time zones are irrelevant here; you are subtracting two instants. See Date and time pitfalls if they drag you into formatting locales. Intl.DateTimeFormat is the wrong tool for a duration.

Lap note, if they ask: push elapsed(state) onto an array. Do not parse the mm:ss.cc string back into ms. The label is a view.

Interview out-loud answer

I would keep accumulated milliseconds and a startedAt timestamp. While running, elapsed is accumulated + (Date.now() - startedAt). Pause adds that open interval into accumulated and clears startedAt. Reset zeros both. An interval or rAF only calls setNow(Date.now()) so the label repaints. I would never increment a counter on a tick, because a throttled timer would drift. Start and pause are one button, so keyboard activation is free. A polite status region announces running or paused, and the digits are not live.

Further reading