Monotonic Clocks — Why You Should Not Subtract Timestamps
· fundamentalsbugs
Subtracting two Unix timestamps to measure a duration gives the wrong answer whenever the clock is adjusted — including negative durations.
Measuring how long something took by subtracting two wall-clock timestamps is one of those techniques that works in testing and fails in production.
const start = Date.now();
doWork();
const elapsed = Date.now() - start; // can be negative
The system clock is not a stopwatch. It is a setting, and settings can change while your code runs.
What moves the wall clock
- NTP corrections.
ntpdandchronyslew small offsets gradually, but step large ones — jumping the clock instantly, forwards or backwards. - VM snapshot resume. A virtual machine restored from a snapshot resumes with a stale clock, then jumps when it re-syncs.
- Container start. A container inherits the host clock, which may not yet be synced.
- Manual changes. Someone runs
date -s, or a user fixes their laptop’s clock. - Daylight saving. Not for UTC-based timestamps, but any code working in local time is exposed twice a year.
- Leap seconds. A scheduled, predictable backwards step — see leap seconds.
A negative duration in your metrics is the classic symptom. A p99 latency of several hours from a single request is the other.
The monotonic clock
Every modern platform provides a second clock that only ever moves forward at a steady rate. It has no relationship to calendar time — its zero point is arbitrary, often boot time — so it is useless for “when did this happen” and correct for “how long did this take”.
// JavaScript — performance.now(), fractional milliseconds
const start = performance.now();
doWork();
const elapsed = performance.now() - start;
# Python — time.monotonic() or perf_counter() for higher resolution
import time
start = time.monotonic()
do_work()
elapsed = time.monotonic() - start
// Go — time.Time carries a monotonic reading; Sub() uses it automatically
start := time.Now()
doWork()
elapsed := time.Since(start) // monotonic, immune to clock changes
// Java — nanoTime(), NOT currentTimeMillis()
long start = System.nanoTime();
doWork();
long elapsedNs = System.nanoTime() - start;
// Rust — Instant is monotonic by construction; SystemTime is not
use std::time::Instant;
let start = Instant::now();
do_work();
let elapsed = start.elapsed();
/* C — CLOCK_MONOTONIC, not CLOCK_REALTIME */
struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
do_work();
clock_gettime(CLOCK_MONOTONIC, &end);
// PHP — hrtime() since 7.3
$start = hrtime(true); // nanoseconds
do_work();
$elapsed = hrtime(true) - $start;
Go deserves a note: time.Now() returns a value carrying both a wall-clock reading and a monotonic reading. t2.Sub(t1) and time.Since(t) use the monotonic component automatically, so Go does the right thing by default. But t.Round(), t.Truncate(), marshalling, and any round-trip through a string strip the monotonic reading, after which subtraction silently falls back to wall-clock arithmetic.
Which to use where
| Question | Clock |
|---|---|
| How long did this take? | Monotonic |
| Has the timeout expired? | Monotonic |
| What is the rate limit window? | Monotonic |
| When did this event occur? | Wall clock (Unix timestamp) |
| When does this token expire? | Wall clock |
| What should the log line say? | Wall clock |
| Is this cache entry stale? | Depends — see below |
The rule: monotonic for durations, wall clock for instants.
The cases that need both
Cache expiry and timeouts are interesting because the answer depends on scope.
Within one process, a monotonic deadline is correct — a five-minute timeout should be five real minutes regardless of clock adjustments.
Across processes or machines, monotonic clocks are incomparable. Their zero points differ, and a value from one host is meaningless on another. Distributed expiry must use wall-clock time, which is exactly why clock skew matters for JWT validation.
So: monotonic inside a process, wall clock across a network, and accept that the latter needs NTP and a tolerance window.
Monotonic does not mean uniform
Two caveats worth knowing:
Suspend behaviour varies. On Linux, CLOCK_MONOTONIC stops during system suspend; CLOCK_BOOTTIME keeps counting. A laptop that sleeps for eight hours will show almost no monotonic elapsed time. If your timeout must account for suspend, use CLOCK_BOOTTIME.
Resolution is not precision. System.nanoTime() reports nanoseconds but is typically accurate to tens or hundreds of nanoseconds. Browser performance.now() is deliberately coarsened — often to 100 microseconds or worse — as a Spectre mitigation. Do not read more precision into the numbers than the platform provides.
What to check in your own code
Search for arithmetic on wall-clock values:
Date.now() -
time.time() -
System.currentTimeMillis() -
Each of those is a duration measured on the wrong clock. In metrics code especially, a single such subtraction is enough to poison a latency histogram the first time NTP steps the clock.
The Unix timestamps this site converts are wall-clock values — the right tool for recording when, and the wrong tool for measuring how long.