Most countdown timer tutorials accumulate ticks: subtract one second every time setInterval fires. That works until the browser throttles your tab. This article builds a timer that derives remaining time from a wall-clock deadline, so backgrounding the tab for thirty seconds doesn't leave the display thirty seconds behind reality.
Reproduce the drift problem
Here's the minimal broken version:
// Don't ship this.
function useBrokenTimer(durationMs: number) {
const [remaining, setRemaining] = useState(durationMs);
useEffect(() => {
const id = setInterval(() => {
setRemaining((r) => Math.max(0, r - 1000));
}, 1000);
return () => clearInterval(id);
}, []);
return remaining;
}
Open a tab running this timer, switch to another tab, wait 20 seconds, switch back. The display might show 15 seconds have passed because the browser reduced the interval frequency while the tab was inactive. Chrome, Firefox, and Safari all throttle background timers — the exact behavior varies across browsers and versions, so you can't predict how much drift you'll get.
The root cause: the timer's state is the accumulated count of callbacks, but callbacks don't fire at guaranteed intervals.
Define the timer state
Replace the single number with a state machine:
type TimerPhase = 'idle' | 'running' | 'paused' | 'finished';
type TimerState = {
phase: TimerPhase;
endsAt: number; // wall-clock deadline while running
remainingMs: number; // frozen value while idle, paused, or finished
};
Four phases, each with a clear meaning:
-
idle: not started yet.
remainingMsholds the full duration. -
running: counting down.
endsAtholds the deadline; remaining is computed live. -
paused: stopped mid-count.
remainingMsholds the frozen remainder. -
finished: hit zero.
remainingMsis 0.
Map transitions before writing callbacks
Treat the timer as a small state machine rather than a collection of unrelated buttons:
idle ---- start ----> running ---- reaches zero ----> finished
^ | ^ |
| | | |
+------ reset --------+ +------ start -------------+
|
pause
|
v
paused ---- start ----> running
|
+-------- reset -----> idle
This map prevents ambiguous behavior. Start while running is a no-op. Start while paused means resume. Start after completion begins a new full round. Reset always returns to an idle full-duration timer and never starts it. Writing these rules down first also gives you a compact test plan before React state or browser scheduling enters the picture.
Store a deadline while running
When the timer is running, the source of truth is endsAt. Remaining time at any moment is:
const remainingMs = Math.max(0, state.endsAt - Date.now());
No matter how many ticks the interval missed, this expression reflects elapsed wall-clock time at the moment of rendering, subject to the system-clock boundary discussed below.
Store a duration while paused
Pause captures the current difference:
// Pause handler
const frozen = Math.max(0, state.endsAt - Date.now());
setState({ phase: 'paused', endsAt: 0, remainingMs: frozen });
Resume creates a fresh deadline from the frozen duration:
// Resume handler
setState({ phase: 'running', endsAt: Date.now() + state.remainingMs, remainingMs: state.remainingMs });
Let the interval repaint, not count
The interval's only job is forcing React to re-render so the display updates. It doesn't carry state:
const [, forceTick] = useState(0);
useEffect(() => {
if (state.phase !== 'running') return;
const id = setInterval(() => forceTick((v) => v + 1), 200);
return () => clearInterval(id);
}, [state.phase]);
200ms gives smooth-looking seconds without excessive renders. The display uses Math.ceil(remainingMs / 1000) so it reads 01:00 at the start and ticks down to 00:01 before finishing — never shows a misleading 00:00 while time is still technically remaining.
Avoid three React lifecycle traps
The deadline solves elapsed-time accuracy, but the hook can still misbehave if its effects and callbacks are careless.
First, create the repaint interval only while the timer is running. The effect above depends on state.phase, so React clears the old interval whenever the phase changes and again when the component unmounts. This cleanup is especially important during development with React Strict Mode, where effects may be mounted, cleaned up, and mounted again to expose unsafe behavior. A missing cleanup can leave two repaint loops active even though the deadline math itself is correct.
Second, use functional state updates for transitions that depend on the previous state. setState((current) => ...) gives rapid Start or Pause interactions the most recent queued value. Reading a captured state inside a memoized callback can apply a transition to an older render and produce surprising pause/resume behavior.
Third, keep the deadline out of the effect dependency list. The interval is not responsible for creating or changing the deadline; it only requests another render. Recreating the interval whenever endsAt changes adds work without improving correctness. The dependency should describe the resource's lifetime: running means the repaint loop exists, every other phase means it does not.
These rules also make the hook easier to inspect. There is one piece of persistent timer state, one short-lived repaint resource, and explicit transition callbacks. If the displayed time is wrong, you can ask whether the deadline is wrong, whether a transition stored the wrong frozen duration, or whether the view simply has not repainted yet. Those are much clearer failure categories than “the interval got weird.”
Recompute when visibility changes
When the user returns to the tab, you want the display to snap to the correct time immediately rather than waiting up to 200ms for the next interval:
useEffect(() => {
const onVisible = () => forceTick((v) => v + 1);
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, []);
A single forced render on visibilitychange does the job. The computation is already endsAt - Date.now(), so no additional logic is needed.
Implement start, pause, reset, and finish
const DURATION_MS = 60_000;
const start = useCallback(() => {
setState((current) => {
if (current.phase === 'running') return current;
const duration = current.phase === 'finished' ? DURATION_MS : current.remainingMs;
return { phase: 'running', endsAt: Date.now() + duration, remainingMs: duration };
});
}, []);
const pause = useCallback(() => {
setState((current) => {
if (current.phase !== 'running') return current;
return { phase: 'paused', endsAt: 0, remainingMs: Math.max(0, current.endsAt - Date.now()) };
});
}, []);
const reset = useCallback(() => {
setState({ phase: 'idle', endsAt: 0, remainingMs: DURATION_MS });
}, []);
Transition from running to finished happens in an effect that watches remainingMs:
useEffect(() => {
if (state.phase === 'running' && remainingMs === 0) {
setState({ phase: 'finished', endsAt: 0, remainingMs: 0 });
}
}, [state.phase, remainingMs]);
Key behaviors:
- Starting from
finishedresets to the full duration automatically. - Starting from
pausedresumes from the frozen remainder. - Reset always returns to
idleat the full duration without starting.
Integrate the timer with a round flow
In the Pictionary tool where I use this hook, generating a new word calls reset() — the clock returns to 01:00 but does not auto-start. The host presses Start when the room is ready.
This is a product decision, not a React limitation. Auto-starting would mean every accidental tap on "next word" immediately begins a countdown. Keeping Start manual gives the host control over pacing.
Similarly, changing the difficulty or category filter resets the timer. The host is adjusting the round setup, so the previous countdown is no longer relevant.
Test the edge cases
| Scenario | What to verify |
|---|---|
| Background tab for 45s during a 60s countdown | Returning shows ~15s remaining, not ~55s |
| Rapid pause/resume | Frozen duration follows the latest deadline within a small timing tolerance |
| Start after finished | Restores full duration, doesn't resume from 0 |
| Unmount during running | Interval is cleared, no state updates on unmounted component |
| System clock jump (e.g., laptop sleep/wake) | Timer may show unexpected values — this is a known boundary, not a solved case |
| Multiple rapid Start clicks | Idempotent — second click is a no-op because phase is already running
|
Note on the clock-jump case: Date.now() reflects the system clock, so a laptop sleeping for 10 minutes and waking will show the timer as finished (since endsAt is now far in the past). This is generally the correct behavior for a game timer — if you left mid-round, the round is over. But it's worth documenting as a boundary rather than claiming "zero drift under all conditions."
Be precise about what “doesn't drift” means
This design corrects callback-scheduling drift. If the browser delays ten interval callbacks, the next render still calculates against the original deadline instead of pretending only one second passed. It does not make setInterval precise, guarantee a repaint at an exact millisecond, or protect against a user manually changing the system clock.
The distinction matters in tests. Avoid asserting that a callback fires at exactly 200ms; that is controlled by the runtime. Assert that, given an endsAt value and a later clock reading, the displayed remainder is derived from their difference. For end-to-end coverage, background the page long enough to trigger throttling, return, and allow a small tolerance around the expected number of seconds. That tests the behavior users care about without turning browser scheduling into a brittle benchmark.
If your application needs monotonic elapsed-time measurement rather than a wall-clock deadline, investigate performance.now(). It has different semantics around navigation, sleep, and persistence, so it is not a drop-in improvement for every countdown. For this one-session game timer, Date.now() makes the intended “the round kept elapsing while you were away” behavior explicit.
Format the display
function formatClock(remainingMs: number): string {
const totalSeconds = Math.ceil(remainingMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
Math.ceil ensures the display shows 00:01 for the final partial second and transitions to 00:00 only when truly finished.
Try the real interaction
I built a Pictionary round tool that uses this exact timer pattern. You can try the 60-second round timer in context — generate a word, start the timer, background the tab, and return to see the corrected display.
The timer is fixed at 60 seconds. It does not offer configurable duration — that's a deliberate constraint for this particular tool, not a limitation of the pattern described here.
Top comments (0)