A countdown that confidently showed time remaining after the deadline had already passed — and the cause had nothing to do with the code. This is part two of a series ( part one is here), but it stands on its own.
TL;DR —
Date.now()is device time: the user can set it wrong, and an NTP daemon can jump it mid-session. Ask an authoritative server — one whose clock defines the deadline — correct for network latency (exactly what NTP does), and derive from that offset. Measure the round-trip with a monotonic clock so a jump can’t corrupt the measurement, and resync onvisibilitychange/online.
The second heisenbug
This timer never went negative. It did something worse — it confidently showed three minutes remaining after the round had already ended. And it hadn’t been backgrounded at all.
This one unraveled faster — I already knew where to look. The tester’s machine had a system clock skewed several minutes off.
My elegant fix — endTime - Date.now() — trusts Date.now() completely. But Date.now() is device time, and device time is a number the user controls. Set your clock five minutes fast and every “derive from the clock” timer on the internet quietly derives from a lie.
Worse, a clock can jump mid-session: an NTP daemon corrects a drifting laptop, or the user toggles their timezone, and Date.now() lurches sideways between two ticks.
So there were never one untrustworthy clock, but two:
- The render loop’s cadence — throttled in the background. (Fixed in part one.)
- The device’s wall clock — skewed, and able to jump. (Not fixed at all.)
Ask the server, not the desk
The fix is the same shape as before: stop trusting a local source, derive from an authoritative one.
Ask an authoritative server — one whose clock defines the deadline — what time it is. (There’s no “real” time to fetch here: if the server’s own clock is off, that’s still the clock your application should honor.) Correct for the network round-trip — exactly what NTP does — then compute the offset between the server’s clock and the device’s, and apply that offset everywhere.
Device clock 12:00:05 ← what the visitor's machine thinks
Server clock 11:59:58 ← the authoritative clock
────────
offset = −7s (server − device, corrected for the round-trip)
trueNow() = Date.now() + offset → 11:59:58, no matter what the desk says
Two refinements make it solid.
Take several samples and keep the one with the smallest round-trip. Least network jitter means the most accurate offset. (Like NTP, the rtt/2 split assumes latency is roughly symmetric — if upload and download times differ a lot, the estimate drifts by the difference. Usually it’s close enough.)
Measure the round-trip with a monotonic clock (performance.now()). It isn’t more accurate than Date.now(); it’s measured from the browser’s own monotonic time origin, so it’s unaffected by clock changes, timezone, DST, or an NTP correction — it never jumps backward or forward. A wall-clock jump landing during a sync therefore can’t corrupt the RTT and poison the offset.
One caveat: the monotonic clock protects the measurement, not the display. A jump between syncs still moves the derived countdown — the offset is only as fresh as the last sync. What keeps the display honest is the resync on visibilitychange / online, which re-measures the offset the moment it might have gone stale.
Two heisenbugs, one principle: the screen is a projection of an authoritative truth, recomputed — never a tally you keep locally and hope stays in sync.
I pulled it into a library
After solving this in more than one product, I realized every countdown implementation ends up rediscovering the same two problems — throttled tabs and wrong clocks, the ones behind every “sale ends in” banner, every auction, every OTP resend. So I extracted the clock-synchronization logic into a tiny, dependency-free library: synced-countdown.
import { createServerClock, fetchTimeFromDateHeader } from 'synced-countdown';
import { useServerCountdown } from 'synced-countdown/react';
// No API route needed — it reads the Date header every HTTP response already sends.
const clock = createServerClock({ fetchTime: fetchTimeFromDateHeader('/') });
clock.sync();
function RoundTimer({ endsAt }: { endsAt: number }) {
const { minutes, seconds, isComplete } = useServerCountdown(endsAt, { clock });
return isComplete
? <span>Settling…</span>
: <span>{minutes}:{String(seconds).padStart(2, '0')}</span>;
}
It syncs to your server’s clock, recomputes remaining time every tick (never decrements), ticks off requestAnimationFrame (which browsers suspend or throttle in hidden tabs) so it does no work while you’re away, and hard-resyncs on visibilitychange and online. No server to sync against? Leave off fetchTime and it degrades to device time — you lose the wrong-clock correction but keep the background-tab fix. Framework-agnostic core, optional React adapter. One caveat on the Date-header trick: it’s only as trustworthy as the infrastructure that sets it — a CDN, reverse proxy, or cache can rewrite or serve a stale Date, so point it at an origin whose clock you trust (or a dedicated /time endpoint when you need certainty).
The one line to remember
The browser kept its promises. Mine were the ones that were wrong — and so were the device clock’s. Both times, the bug was trusting a local value instead of deriving from an authoritative one.
Every bug in this series came from that same mistake: treating a convenient local value as the truth. Timers, caches, React state, replicas, browser APIs — they all get simpler once you stop accumulating guesses and start deriving from authoritative data.
Derive, don’t accumulate. And when the clock matters, don’t trust the one on the desk in front of you.