Guide
How the timers stay accurate
Why every timer built on the timing engine derives its display from a stored epoch anchor rather than counting ticks, and what that means after a reload.
One number, stored once
Every timer built on the timing engine — the countdown, the Pomodoro timer, the interval timer and the speech timer — stores an epoch anchor: a single instant, recorded when the timer starts, as the value the platform's Date.now() reports. It stores nothing else about time: no count of ticks and no running total of seconds, since either would be a second copy of the truth that could disagree with the first.
Every example in this guide starts from one fixed instant, 2026-03-03T14:00:00.000Z, which is a constant in the page rather than the time you opened it. Start the countdown at its default length of 5 minutes — the default you can change in the tool — at that instant and the engine records this, and only this:
{"status":"running","endsAt":1772546700000}
The status says the run is live, and endsAt is the instant it should reach zero: 5 minutes after the start, which is the default duration added to the anchor. A count-up run stores the opposite anchor, the instant it started from, under anchorAt. The countdown and the Pomodoro timer use the first shape; the interval timer and the speech timer use the second. Both shapes have a third form, idle, which stores nothing at all.
The reading is a subtraction
On each frame the display is derived from the difference between the stored end and now. The function that does it takes the run and the instant — plus one display option, covered below, that keeps counting past zero — and nothing that depends on an earlier call: there is no argument for the previous reading, the number of frames so far, or how long ago the last one ran. Here is the default 00:05:00 countdown read at six instants after its start, chosen to be irregular on purpose. The Display column is what the countdown tool shows: it always carries the hours field, even on a value this short.
| Milliseconds after start | Stored end minus now | Display | Expired |
|---|---|---|---|
1000 | 299000 | 00:04:59 | no |
7350 | 292650 | 00:04:53 | no |
63000 | 237000 | 00:03:57 | no |
299999 | 1 | 00:00:01 | no |
300000 | 0 | 00:00:00 | yes |
300001 | 0 | 00:00:00 | yes |
Read any row on its own. Its reading does not depend on whether the rows above it ever happened. A page that ran a thousand frames and a page that ran one arrive at the same number for the same instant, and that is the whole of the drift-free claim: nothing accumulates, so there is nothing for error to accumulate in.
Past the end, the remaining time is clamped at zero for the timers that stop there, and the run reports itself expired. The same function has an option to keep counting below zero, so that a display can show how far over it is rather than a frozen 00:00:00; the clock formatter renders such a value with a leading minus, rounded away from zero, so one millisecond past the end formats as -00:01. The speech timer, which keeps counting past zero, arrives at its figure another way: it holds a count-up anchor, takes its remaining time as the talk's length minus the time elapsed, and presents the amount over behind a plus sign. A talk of the default length, 20 minutes — a default you can change before the talk starts — reads +00:01 one millisecond past its end. The speech-timer guide covers that readout and its bands.
Pause keeps a balance; resume moves the anchor
A paused run cannot hold an end instant, because the end is no longer approaching, so pausing converts the run into a balance. Pause the default countdown 2 minutes after its start and the stored state becomes:
{"status":"paused","remainingMs":180000}
That balance reads 00:03:00 at the pause and still reads 00:03:00 7 minutes later, because a paused run has no anchor to measure against. Resuming adds the balance to the instant of the resume to make a new end: resume after those 7 minutes and the display reads 00:03:00, the figure it was paused at, while the stored end has moved 7 minutes later than the original, to 12 minutes after the start — exactly the paused length, because that is the only thing that changed.
The count-up state does the same thing in the other direction. A count-up run started at the fixed instant reads 06:00 when it is paused 6 minutes in, stores {"status":"paused","elapsedMs":360000}, and still reads 06:00 after 14 minutes on pause. On resume the anchor moves forward to 14 minutes after the original start, so that now minus anchor continues from the stored total: 21 minutes after the original start it reads 07:00. Paused time is simply not counted.
Phases chain from their ends, not from the tick
A single countdown has one anchor. The Pomodoro timer has a sequence of them, one per phase, and this is where a tick-counting design would leak time: if each phase started from the moment the page noticed the previous one had ended, every boundary would carry the latency of the tick that noticed it. The engine chains each phase from the previous phase's end instant instead, and its header states the consequence: a hundred consecutive phases end exactly where arithmetic says they should.
Run from the fixed instant with the defaults (25 minutes of focus, 5 minutesfor the short break, both editable in the timer's settings), the demonstration rolls the session forward at five instants chosen to land late, the first of them 400 milliseconds after the first boundary. Every boundary the engine reports falls on the arithmetic instant, not on the tick that found it:
| Phase completed | Next phase | Boundary after start | Focus intervals done |
|---|---|---|---|
| Focus | Short break | 25 minutes | 1 |
| Short break | Focus | 30 minutes | 1 |
| Focus | Short break | 55 minutes | 2 |
| Short break | Focus | 1 hour | 2 |
After the last of those ticks, 1 hour 1 minute 37 seconds in, the session is in a focus phase that ends 1 hour 25 minutes after the start, and the display reads 23:23. Roll the same session forward once instead, straight from its start to that last instant. The single call crosses 4 boundaries where the five calls crossed 4, the stored end instants are identical, and the display reads 23:23. The engine's own comment on this function says it is safe to call from a quarter-second tick or after twenty minutes in the background, with an identical result either way; the figures above are that statement run. The interval timer needs no chain at all: its session is compiled into a flat plan with cumulative offsets, so “where am I?” is one subtraction against the single start epoch.
A reload resumes from the saved epoch
Running state is written to this browser's storage as epochs, never as “seconds left”. The persistence helpers are pure: they take and return strings, and the timer components own the storage calls. Save the default countdown 1 minute 30 seconds after its start and the envelope written is:
{"v":1,"savedAt":1772546490000,"state":{"status":"running","endsAt":1772546700000}}
It carries a version, the saved instant and the run exactly as the engine holds it. Decode it 1 minute 50 seconds later, as a reload would, and the restored run reads 00:01:40. Had the envelope held the balance at saving, 00:03:30, a reload would have shown that stale figure and the 1 minute 50 seconds away would have vanished. Because it holds the end instant, the reload lands where the wall clock says it should, which is what the engine means by resuming after a phone has been in a pocket.
The saved instant is checked against the clock before anything is restored: an envelope older than 12 hours is discarded, and so is one dated further ahead of the clock than the engine tolerates. With the reload instant above as the clock, the example envelope is restored, and four others fare as follows:
- An envelope saved 11 hours 59 minutes earlier is kept.
- An envelope saved 12 hours 1 minute earlier is discarded.
- An envelope dated 30 minutes ahead of the clock is kept.
- An envelope dated 2 hours ahead of the clock is discarded.
A discarded envelope opens the timer idle rather than resuming something stale, and each timer writes under its own versioned key, for example wrldclock.timer.countdown.v1.
How the display rounds
The clock string rounds seconds up, so a fresh 25:00 timer reads 25:00 rather than one second less, and the display changes only once a whole second has gone. Here is the default focus interval formatted at six remaining values, each a fixed number of milliseconds after its start:
| Milliseconds after start | Remaining (ms) | Display |
|---|---|---|
0 | 1500000 | 25:00 |
1 | 1499999 | 25:00 |
999 | 1499001 | 25:00 |
1000 | 1499000 | 24:59 |
1001 | 1498999 | 24:59 |
1500 | 1498500 | 24:59 |
Negative values are rendered with a leading minus and rounded away from zero for the same reason. That is the formatter's rule rather than any readout's: the speech timer hands the formatter the amount over as a positive number and adds the plus sign itself, so one millisecond past the end it shows +00:01 where the formatter alone would give -00:01. Zero itself has no sign and reads 00:00:
| Milliseconds | Display |
|---|---|
0 | 00:00 |
-1 | -00:01 |
-999 | -00:01 |
-1000 | -00:01 |
-90000 | -01:30 |
Hours appear once there is an hour to show, as in 01:00:01. The countdown tool is the exception: it forces the hours field on every reading, so its default duration reads 00:05:00 rather than 05:00, and that is why the countdown figures in this guide carry three fields where the focus interval's carry two. The browser tab title uses the same clock string followed by the phase label, so the Pomodoro session above would show as 23:23 · Focus in the tab strip.
Where the guarantee stops
What the anchors guarantee is narrow and exact: whenever a tick does run, the display is correct for that instant, and a reload resumes from the stored epochs. The behaviours below are described, not simulated: they come from the tool pages and the engine, not from anything measured on your device.
- The countdown recomputes the remaining time from the system clock several times a second, and while it runs the browser tab shows the remaining time, so a backgrounded timer is still readable from the tab strip. The same holds for every timer on the engine: because the reading is derived from the clock rather than from a tick counter, a backgrounded tab catches up exactly when you return.
- The alarm is not built on this engine. It is scheduled inside its own page, so the tab has to stay open for it to sound, and phones and laptops that fall asleep can delay or suppress the tone entirely.
- The stopwatch is not built on it either. It measures elapsed time against a monotonic browser clock, so it is unaffected by the system clock being corrected while it runs; its own page says to treat its hundredths as accurate to a few hundredths rather than to the millisecond, since browser timers are deliberately coarse.
Questions
- Does a timer that has been running for an hour drift?
- No. The display is the stored end instant minus now, computed afresh on every frame; nothing is accumulated tick by tick, so an hour-old timer is as accurate as one that has just started however irregular the ticks were.
- Why does a fresh timer read 25:00 instead of one second less?
- Seconds are rounded up. A timer set to 25 minutes reads 25:00 until a whole second has gone, then 24:59; it never shows a second it has not yet finished. Overtime is rounded away from zero for the same reason: the formatter renders one millisecond below zero as -00:01, and the speech timer, which presents overtime behind a plus sign, shows that same millisecond past the end as +00:01.
- What happens if I reload the page mid-count?
- The timer resumes where the wall clock says it should be. The saved envelope holds the end instant, not the seconds left, so the time the page was away is counted. Above, an envelope saved reading 00:03:30 is restored 1 minute 50 seconds later reading 00:01:40. Envelopes older than 12 hours are discarded and the timer opens idle.
- Why did my paused timer not move while it was paused?
- Because a paused run stores a balance rather than an end instant: in the example, 00:03:00 stays 00:03:00 however long the pause lasts. Resuming adds that balance to the instant of the resume, so the end moves later by exactly the paused length.
- Does the timer keep running in a background tab?
- Timing is derived from the clock rather than from a tick counter, so whenever a frame runs the display is correct for that instant and a backgrounded tab catches up exactly when you return. This is described, not simulated: the alarm, which is scheduled inside its own page, still needs the tab open to sound.
- Is the stopwatch built the same way?
- No. The stopwatch measures elapsed time against a monotonic browser clock and shows hundredths of a second, so it is unaffected by a system clock correction while it runs. The anchored timers read the wall clock on purpose, because that is what a saved epoch is compared with after a reload.
Everything here runs in your browser: the figures above were computed from the engine when the site was built, and the timers themselves run on this device's own clock. The methodology page describes the whole engine, and the guides index lists the other guides.