The surprise
Here's a JavaScript snippet that, on rare occasions in production, will fail:
const start = Date.now();
doSomeWork();
const end = Date.now();
const elapsed = end - start;
console.log(`Took ${elapsed}ms`);
// → Sometimes: "Took -1832ms"
How can end be smaller than start? The work happened in real time. The function calls were sequential. What's going on?
Date.now() reads the wall clock
Date.now() returns the system wall clock — the same clock that's displayed in your operating system's status bar. That clock has at least two ways to go backwards:
- NTP correction. Your machine's clock drifts. The operating system periodically syncs to a time server. If your clock was running fast and got corrected, the wall clock will jump backwards by however much it was off.
- Manual user adjustment. Someone in Control Panel changes the time.
- Daylight saving time — sort of. DST changes the displayed local time, but
Date.now()returns milliseconds since the Unix epoch, which is timezone-independent. So DST itself doesn't cause backward jumps. But on some operating systems with broken time handling, the DST transition can trigger a corrective NTP sync. - Virtual machine resume. When a VM is suspended and resumed, its clock may snap forward or backward to match the host. Containers can exhibit similar behavior on Linux when the host clock drifts.
Any of these can cause two consecutive calls to Date.now() to return non-monotonic values. It's rare but happens often enough in production to be a real bug source.
The fix: performance.now() for durations
performance.now() reads a different clock — one that is guaranteed to be monotonic. It returns a high-resolution timestamp (sub-millisecond precision) since the page load (in browsers) or process start (in Node). It cannot go backwards.
const start = performance.now();
doSomeWork();
const end = performance.now();
const elapsed = end - start;
// elapsed is guaranteed to be >= 0
Use performance.now() for anything that measures a duration. Use Date.now() only when you need to record an absolute moment in time (a log entry, a database row, a scheduled event). Never use Date.now() for timing.
The other failure mode: ID generation
Here's a common pattern that breaks in interesting ways:
// Generate a "unique" ID using the current time
function makeId() {
return Date.now().toString(36);
}
This works fine until two events happen in the same millisecond — then you get a duplicate. People usually add a random suffix:
function makeId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}
This is better, but it still has the non-monotonic problem. If your IDs are supposed to sort chronologically (a property that's often assumed without being stated), a clock-jump backwards can produce IDs that sort earlier than older IDs. If you're using IDs as cursor values in a paginated API, this is a real bug.
The fix: use UUIDv7 (or ULID, or similar). UUIDv7 explicitly handles the non-monotonic case — it remembers the last issued timestamp and ensures any new ID is at least 1ms greater, even if the wall clock went backwards. As of Node 20+, you can generate them with the crypto module; libraries exist for older Node and for browsers.
The sneakier failure: cache expiration
Here's a TTL cache implementation that looks correct but has the same bug:
class TtlCache {
constructor(ttlMs) {
this.ttl = ttlMs;
this.store = new Map();
}
set(key, value) {
this.store.set(key, {
value: value,
expiresAt: Date.now() + this.ttl
});
}
get(key) {
const entry = this.store.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return undefined;
}
return entry.value;
}
}
This works fine until the system clock jumps forward. Say it jumps forward by 1 hour due to an NTP correction. Every entry in the cache instantly expires, because Date.now() is now an hour ahead of when they were set. Your cache hit rate drops to near zero, your downstream service gets hammered, and you wonder what's happening.
The fix: again, use performance.now() for the deadline math (since it's monotonic), or store TTL as a duration rather than an absolute time and compute "now + duration" each check.
Better still: use a real cache library that handles this. There's no shame in not implementing your own TTL cache.
What you can do
- Use
performance.now()for durations. NeverDate.now() - Date.now(). - Use UUIDv7 for time-ordered IDs. Don't roll your own from
Date.now(). - For cache TTLs, use monotonic time. Or use a library that does.
- For logging absolute timestamps,
Date.now()is fine. Just don't rely on monotonicity. - Test for backwards-jumping scenarios. Mock
Date.now()in tests and verify your code handles a clock that suddenly moves backwards by an hour.
Browser variance
One additional wrinkle: even performance.now() isn't perfectly precise across all browsers. To prevent timing attacks, browsers intentionally reduce its precision:
- Chrome rounds to 100μs (0.1ms) when cross-origin isolation isn't enabled, or 5μs when it is
- Firefox rounds to 1ms or 100μs depending on context
- Safari rounds to 1ms
So performance.now() won't give you nanosecond precision in a normal web context. That's fine for almost all use cases — but if you genuinely need high-precision timing for benchmarks or audio sync, look at the MDN reduced time precision docs for how to enable cross-origin isolation and get sharper measurements.
For day-to-day work: use performance.now() for elapsed time, Date.now() for wall-clock moments, and forget you ever expected wall-clock time to be monotonic.
Published April 25, 2026. Tagged: javascript, time, gotchas.