~/blog

Unix timestamp bugs: seconds vs milliseconds, Y2038, and the off-by-1000 that ships

published

#javascript#time#debugging

Large glowing digits built from smaller numbers on a dark background, shifting from neon green to pink across the frame
Generated illustration

TL;DR

The single most common epoch bug is mixing units: JavaScript counts milliseconds, most of Unix counts seconds, and a value off by a factor of 1000 lands you in 1970 or year 52,000. After that comes the 2038 signed-32-bit overflow, leap-second handling, and the reminder that an epoch is always UTC — the timezone trouble starts when you format it.

The off-by-1000

The Unix epoch is “seconds since 1970-01-01T00:00:00Z.” But not every platform agrees on the unit, and the disagreement is silent:

Platform / callReturnsUnit
JS Date.now(), new Date().getTime()1718000000000milliseconds
Unix date +%s1718000000seconds
Python time.time()1718000000.123seconds (float)
Go time.Now().Unix()1718000000seconds
Go time.Now().UnixMilli()1718000000000milliseconds
Java System.currentTimeMillis()1718000000000milliseconds
Java Instant.getEpochSecond()1718000000seconds
Postgres extract(epoch from now())1718000000.45seconds (float)

Feed a seconds value to new Date() in JavaScript and you get a moment 1000× too early — new Date(1718000000) is 21 January 1970, not June 2026, because new Date(number) expects milliseconds. Feed a milliseconds value to a seconds-based API and you land tens of thousands of years in the future.

The reliable tell is magnitude. A seconds timestamp for “now” is 10 digits and stays 10 digits until November 2286; the millisecond equivalent is 13 digits. A safe normaliser:

// Coerce an ambiguous epoch to milliseconds.
// Anything below ~1e12 is almost certainly seconds.
const toMillis = (n) => (n < 1e12 ? n * 1000 : n);

new Date(toMillis(1718000000)); // seconds in  → correct
new Date(toMillis(1718000000000)); // millis in → correct

This heuristic breaks only for timestamps before ~2001 (sub-1e9 seconds) or in the far future, which is acceptable for almost every real dataset.

Y2038

Systems that store Unix time in a signed 32-bit integer can hold at most 2**31 - 1 = 2147483647 seconds. That value is reached at 2038-01-19T03:14:07Z. One second later the counter overflows to negative and wraps to December 1901. This is the Year 2038 problem, and it is the reason 32-bit time_t is being replaced by 64-bit across operating systems and databases.

2**31 - 1 = 2147483647  →  2038-01-19T03:14:07Z   (last valid 32-bit second)
            2147483648  →  1901-12-13T20:45:52Z   (overflow wrap)

You are exposed if any layer — an embedded device, a legacy database column, a serialization format, an old C library — still narrows the value to 32 bits. A 64-bit signed integer pushes the limit past the year 292 billion, so the fix is to make sure the type is 64-bit end to end.

Leap seconds and smearing

UTC is occasionally adjusted by a leap second to stay aligned with Earth’s rotation. Unix time, by definition, ignores leap seconds — it pretends every day has exactly 86,400 seconds. When a leap second is inserted, the Unix clock either repeats a value or the surrounding infrastructure has to absorb the discontinuity. Naively stepping the clock backward breaks timers and ordering assumptions.

The common production fix is leap smearing: spread the extra second across a window (e.g. 24 hours) so the clock never jumps. Google documented its smear and the public NTP servers that implement it (Google — Leap Smear). If you compare timestamps across systems with different smear policies during a leap event, expect sub-second disagreement.

Timezones

An epoch value has no timezone — it is an absolute count of seconds from a UTC instant. Two machines anywhere on Earth that capture Date.now() at the same moment record the same number. Timezones enter only when you format an epoch into a human calendar date, or parse a human date into an epoch.

const t = 1718000000000;
new Date(t).toISOString();              // '2024-06-10T06:13:20.000Z'  (UTC, unambiguous)
new Date(t).toLocaleString('en-US', { timeZone: 'America/New_York' });
                                        // formats the same instant in NY time

Store and transmit UTC (or the raw epoch). Apply a timezone only at the display edge. The classic bug is parsing '2024-06-10' with new Date('2024-06-10'), which JavaScript treats as UTC midnight — then displaying it in a negative-offset zone shows the previous day.

What to do

When a timestamp looks wrong, paste it into the timestamp converter to see seconds, milliseconds, and the ISO form side by side — that usually exposes an off-by-1000 instantly.

Caveats

References