Build a Format-Demo Countdown That Handles Pauses and Late Updates
Build a Format-Demo Countdown That Handles Pauses and Late Updates
A countdown that subtracts one step per callback isn't measuring time. It's measuring the browser's willingness to call you back, and it will report whatever that willingness produces. On a busy machine, an inactive tab, or a laptop that just woke up, those are two different quantities, and only one of them belongs on screen.
Here is the rule the rest of this article unpacks. Pick the time basis before you write the display: either the format counts active play, or it counts wall time toward a deadline. Keep a start instant or a deadline in that basis, and derive the remainder from a timestamp difference at whatever moment you happen to render. Make expiry a one-way state change so a late callback can't award twice. And when continuity is lost — reload, sleep, a system-clock correction — hold the display and hand the decision to a person, rather than repairing it from a saved number that has no way to prove itself.
The traces and code below are design sketches built on supplied timestamps. They were not produced by a browser session, and a local demo of this kind cannot certify broadcast timing, competition fairness, or synchronization between devices. What it can do is fail in ways you can predict.
Choose what a pause is allowed to stop
Two pause rules are both defensible, and they answer different questions. Active-play time excludes declared pauses; the round has sixty seconds of play in it and will get all sixty, whatever the interruption costs. Wall-elapsed time keeps running toward a deadline while play is interrupted; the round ends when the clock says so, whether or not anyone got to play.
Take a sixty-second round, started at page clock 1,000 ms, paused at 22,000, resumed at 40,000. The interruption lasted eighteen seconds.
Under active play, the round's remaining time at the resume is still 39,000 ms. The expiry instant lands at 79,000 on the page clock — sixty seconds of play, pushed eighteen seconds later by the pause.
Under wall elapsed, the deadline was fixed at 61,000 when the round started, and the pause changes nothing about it. The round ends on time with 42,000 ms of play actually played. Eighteen seconds of the declared sixty are simply gone.
Neither of those is a bug. They are the two things a format can mean, and the argument for one over the other is an argument about the show: does the running time bend, or does the contest bend? A demo can display either. It cannot decide which one the format intends, and that decision belongs to whoever owns the format.
The same separations apply to authority. Pausing, resuming, and resetting are three actions, not one, and they do not have to sit with the same person. Pausing is a play decision — something happened in the room. Resetting is a format decision — this run is void and a new one begins. Write down who holds each, because the code will happily let anyone do anything.
Calculate remaining time from timestamps
The remainder at any instant is a subtraction, performed on demand:
// Paper sketch, not executed while writing this article.
const DURATION_MS = 60_000;
const BASIS = 'active-play'; // or 'wall-elapsed'
let state = 'ready'; // ready | running | paused | expired | held
let startedAt = 0; // page clock at start
let startedAtWall = 0;
let pausedAt = 0;
let pausedTotalMs = 0;
let deadlineWall = 0;
let heldRemainderMs = 0;
let lastTrustedMs = DURATION_MS;
const page = () => performance.now();
function start() {
startedAt = page();
startedAtWall = Date.now();
deadlineWall = startedAtWall + DURATION_MS; // fixed once, on the wall basis
pausedTotalMs = 0;
lastTrustedMs = DURATION_MS;
state = 'running';
}
function activeElapsed() {
if (state === 'ready') return 0;
if (state === 'paused') return (pausedAt - startedAt) - pausedTotalMs;
return (page() - startedAt) - pausedTotalMs;
}
function remainingMs() {
if (state === 'held') return heldRemainderMs; // frozen, never recomputed
if (BASIS === 'wall-elapsed') return Math.max(0, deadlineWall - Date.now());
return Math.max(0, DURATION_MS - activeElapsed());
}
function paint(ms) {
display.textContent = format(Math.ceil(ms / 1000));
}
function render() {
const r = remainingMs();
paint(r);
lastTrustedMs = r; // what the display last stood behind
const live = state === 'running' || (BASIS === 'wall-elapsed' && state === 'paused');
if (live && r === 0) expire();
}
function hold(reason) {
heldRemainderMs = lastTrustedMs; // freeze, don't recompute
state = 'held';
log.push({ runId, held: reason, remainderMs: heldRemainderMs });
}
Three things are doing quiet work here. remainingMs() takes no argument and carries no memory of the last call; it's a function of the current timestamp, so a callback that arrives late is late for nothing. Math.max(0, …) clamps the number so a negative remainder can't leak into the display. And Math.ceil means a partial final second still reads as one second, so the round visibly spends its last second rather than jumping from two to zero — a display convention, not a timing one, but it's the difference between a countdown that feels honest and one that flickers.
start() and hold() are in the sketch to keep its two promises honest. start() is the only place that fixes the wall deadline, so the deadline is never a number nobody wrote. hold() copies the last remainder the display rendered into heldRemainderMs, which makes the freeze a value the page actually stood behind rather than one recomputed after the clocks disagreed.
The authority is the timestamp. The rendering frequency is a user-experience choice. You can drive render() from requestAnimationFrame or from an interval, and neither one becomes more correct by running more often.
The two clocks are not interchangeable, and MDN's performance.now() page is explicit about why: the value is relative to a time origin rather than a calendar date, and it is monotonic — unlike Date.now(), which is subject to system-clock adjustment. That makes the page clock the right authority for a round that is actively being played on a visible page, and the wrong authority for anything that has to survive a reload, because the time origin belongs to the page that created it.
The page's documentation also notes that whether the clock keeps ticking across system sleep varies by operating system and implementation. That's the caveat to design around rather than argue with. You cannot assume the sleep interval was counted, and you cannot assume it wasn't, so the code should be able to say "I don't know" instead of quietly choosing.
Late callbacks are the other documented fact. MDN's setTimeout() page describes callbacks arriving later than requested, inactive-tab throttling, and delayed timeouts, without promising a specific throttle period that holds across browsers. The practical reading: the delay is real, its size is not yours to predict, and counting callbacks to measure elapsed time is therefore unsound.
Make expiry a state change, not a repeated condition
Five states cover this: ready, running, paused, expired, held. The transitions are worth writing out, because two of them carry side effects and the rest don't.
| From | Trigger | To | Side effect |
|---|---|---|---|
| ready | start | running | record startedAt and startedAtWall; fix deadlineWall at startedAtWall + DURATION_MS; reset pausedTotalMs and the frozen remainder |
| running | pause | paused | record pausedAt |
| paused | resume | running | pausedTotalMs += now - pausedAt |
| running | remainder reaches 0 | expired | emit the award exactly once |
| paused | remainder reaches 0 | expired | only possible under wall-elapsed |
| running, paused | suspected discontinuity | held | freeze the last trusted remainder |
| any | reset | ready | new run id; previous result appended to the log |
The fifth row is worth pausing on. Under active play, a paused round cannot expire, because elapsed time isn't moving. Under wall elapsed, it can — the clock reaches the deadline while play is interrupted, and the operator still has to do something about it. The same pause produces an impossible transition under one rule and an ordinary one under the other.
Now the guard that makes expiry happen once:
function expire() {
// Late callbacks land here and stop. Under wall elapsed the deadline can
// arrive while play is paused, so both live states are allowed through.
const live = state === 'running' || (BASIS === 'wall-elapsed' && state === 'paused');
if (!live) return;
state = 'expired';
award(); // sound, score, transition, whatever
log.push({
runId,
expiredAt: BASIS === 'wall-elapsed'
? deadlineWall
: startedAt + DURATION_MS + pausedTotalMs
});
}
The guard is the whole feature. Once the state is expired, every subsequent callback — however many arrive, however far past the zero — recomputes a remainder of zero, paints zero, and returns. No second award, no second sound, no state churn. The award is attached to the transition, not to the condition. The paused clause earns its place under wall elapsed: a deadline can arrive while play is stopped, and that run still owes exactly one award when it does.
That distinction matters for reset too. A reset creates a new run with a new identifier and appends the finished run to a log. If reset silently clears the result, the operator has no record of what the previous round produced, and the display becomes the only memory in the system.
One pause, a late update, two callbacks after zero
Here is the trace, using the round from earlier and supplied timestamps. Round duration 60,000 ms; start at page 1,000; pause at 22,000; resume at 40,000; the render callback that should have fired near expiry doesn't run until 80,400, and two more follow at 80,700 and 81,000.
Active-play basis. The expiry instant is 79,000 on the page clock.
| Page clock | Event | State after | Display |
|---|---|---|---|
| 1,000 | start | running | 01:00 |
| 22,000 | pause | paused | 00:39 |
| 30,000 | render during pause | paused | 00:39 |
| 40,000 | resume | running | 00:39 |
| 79,000 | expiry instant passes; no callback runs | running | 00:01 (stale) |
| 80,400 | late render | expired | 00:00, award ×1 |
| 80,700 | render | expired | 00:00, no award |
| 81,000 | render | expired | 00:00, no award |
The display says 00:39 at the pause and 00:39 at the resume. Same digits, eighteen seconds of table time apart, and the identical number is exactly what the rule promises: the interruption didn't consume any of the round.
The late callback costs 1,400 ms of screen truth, not 1,400 ms of round time. At the moment the display first reads zero, active play stands at 61,400 ms — 1,400 ms past the declared length, entirely because the screen learned the answer late. The model knew at 79,000. This is the distinction to keep straight: the clock is authoritative, the screen is a report, and a late report doesn't move the expiry.
Wall-elapsed basis. Same actions, deadline fixed at 61,000.
| Page clock | Event | State after | Display |
|---|---|---|---|
| 1,000 | start | running | 01:00 |
| 22,000 | pause declared | paused | 00:39 |
| 30,000 | render during pause | paused | 00:31 |
| 40,000 | resume | running | 00:21 |
| 61,000 | deadline reached | expired | 00:00, award ×1 |
The mid-pause row is the whole difference: under wall elapsed, the number falls while nobody can play. And the round ends with 42,000 ms of play actually played against a declared sixty.
A backward clock correction. Wall basis, running, 12,000 ms remaining at wall 13:05:00. The system clock is corrected backward by thirty seconds. deadlineWall - Date.now() now reads 42,000, and the display gains half a minute of time it doesn't have. The forward version of the same event is worse: a thirty-second jump forward drives the remainder to zero and expires a round that still had play in it. One direction lies about how much time is left; the other ends the round early and fires the award.
The check is a comparison, not a solution:
let last = { wall: Date.now(), page: performance.now() };
function checkContinuity() {
const wall = Date.now(), pageNow = performance.now();
const wallDelta = wall - last.wall;
const pageDelta = pageNow - last.page;
last = { wall, page: pageNow };
if (Math.abs(wallDelta - pageDelta) > 2_000) hold('clock disagreement');
}
If the two clocks advance by meaningfully different amounts between renders, something happened that the page can't identify: a manual correction, an NTP step, or a sleep during which the monotonic clock stopped while wall time kept going. Any of those is a reason to stop guessing. Note carefully what a passing check means — that the two clocks agree over that interval, which is not the same as the round having been played for that interval. Agreement removes one doubt. It doesn't create evidence.
Compare continuity strategies when the page returns
Judge the three obvious approaches against the same events: an eighteen-second declared pause, a 2,400 ms late callback, a tab left for four minutes and twenty-two seconds, and a thirty-second clock correction.
| Same-page active-play clock | Persisted wall deadline | Operator-owned countdown | |
|---|---|---|---|
| Late callback | Screen lags; model doesn't | Same, if the render reads the deadline | The operator's hand is the model |
| Declared pause | Removed from elapsed time | Cannot represent it; the pause just burns clock | Operator stops counting |
| Reload | Time origin resets; no local history survives | Deadline restored, play history invented | Travels with the operator |
| Machine sleep | Implementation-dependent per MDN | Wall clock includes sleep; internally consistent | Operator decides |
| Clock correction | Invisible — monotonic clock ignores it | Silently gains or loses time | Operator checks against an outside reference |
| Effort | Lowest | Low | Highest |
| Who owns the result | The page | The page, unless it holds | The person |
Reload deserves its arithmetic spelled out, because the arithmetic is fine and the model is wrong anyway. A run starts at wall 13:02:14 with sixty seconds of active play and 18,000 ms of accumulated pause, saved at 13:02:50. The page comes back at 13:07:12. Reconstructing from the saved stamps gives 298,000 ms of wall time minus 18,000 ms of pause — 280,000 ms of "active play," which puts expiry 220,000 ms in the past.
Every digit of that calculation is defensible and the conclusion is nonsense. For four minutes and twenty-two seconds there was no running page. Whether anyone was playing during that interval is not a question the saved numbers can answer, and a countdown that resumes from the reconstruction has invented a contest that nobody held. The page clock offers no help here either — a reloaded page gets a fresh time origin, so there is no local monotonic history to check the wall reconstruction against.
The alternative is to treat the reload as an event rather than an error condition. On load, present the saved record — started when, active play consumed, pauses recorded, deadline if any — and offer exactly three resolutions: resume, restart, or void. Nothing resumes automatically. The display shows the frozen remainder until someone decides.
This is also where visibilitychange and unusually long gaps between renders are useful, as suspicion rather than proof. A hidden tab doesn't prove the clock stopped; a visible one doesn't prove it kept ticking. Use them to raise the question and hold() to stop the display from answering it.
The rule, the trace, the policy
Pick one basis and write it down in the format's own words. "The round is sixty seconds of active play" and "the round ends sixty seconds after it starts" are both complete rules, and both are implementable with the sketch above. What isn't implementable is leaving the choice implicit and letting the callback schedule decide it.
Keep one trace — the four-row active-play table is enough — as the reference for what late, paused, and expired are supposed to look like. When a bug report says the timer is wrong, that table tells you whether it's the model or the screen.
And write the restart policy into the demo, not just the documentation. Any detected discontinuity moves the run to held, the display freezes at the last remainder the page could vouch for, and only a named operator can resume, restart, or void. That is a worse demo than one that recovers by itself, and a better one than a demo that tells the room a number it cannot support. A local clock is one machine's opinion, arrived at with whatever continuity that machine happened to get. Say so on the screen when it stops being able to.
Frequently asked questions
What are the two possible time bases for a format-demo countdown, and how do they treat a pause?
Active-play time excludes declared pauses: a sixty-second round still gets all sixty seconds of play, and an eighteen-second pause pushes expiry from 61,000 to 79,000 on the page clock. Wall-elapsed time fixes a deadline at the start, so the same pause changes nothing: the round ends at 61,000 with 42,000 ms of play actually played. Neither is a bug; they are different format meanings, and the decision belongs to whoever owns the format.
Why should remaining time be calculated from timestamps rather than counting callbacks?
Counting callbacks measures the browser's willingness to call back, not time. Documented setTimeout behavior includes late callbacks, inactive-tab throttling and delayed timeouts, with no predictable throttle period across browsers. A remaining-time function that takes no argument and reads the current timestamp is a function of the clock, so a late callback is late for nothing. The page clock is monotonic and relative to a time origin, unlike the adjustable wall clock, which makes it the right authority for active play on a visible page and the wrong authority across a reload.
How can expiry be made to happen exactly once despite late callbacks?
Use states such as ready, running, paused, expired and held, and make expiry a one-way transition. The expire function should allow only live states through, set the state to expired, emit the award once, and let every later callback recompute zero, paint zero and return. Under wall-elapsed time, a paused round can reach the deadline and still owes exactly one award. Reset should create a new run identifier and append the finished run to a log rather than silently clearing the result.
What should happen when continuity is lost through reload, sleep or a clock correction?
A continuity check can compare wall-clock and page-clock deltas and call hold when they differ by more than about two seconds. A reload cannot be repaired from saved stamps: saved numbers can reconstruct 280,000 ms of active play and place expiry 220,000 ms in the past, but no running page existed for four minutes and twenty-two seconds, and the saved numbers cannot say whether anyone was playing. Treat reload as an event, present the saved record, and offer resume, restart or void, with nothing resuming automatically.
What should be written down for the format and the demo's restart policy?
Pick one basis and write it in the format's own words; 'the round is sixty seconds of active play' and 'the round ends sixty seconds after it starts' are both complete rules. Keep one trace as a reference for what late, paused and expired should look like. Write the restart policy into the demo: any detected discontinuity moves the run to held, the display freezes at the last remainder the page could vouch for, and only a named operator can resume, restart or void. A local clock is one machine's opinion, and the demo should say so when it cannot support a number.