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. ntpd and chrony slew 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.

Related guides