BriskFile

Guides ·

Why timestamp converters get nanoseconds wrong

Nanosecond timestamps are larger than JavaScript can hold exactly, digit-counting only identifies seconds between 2001 and 2286, and adding a time to a date changes which instant it means.

Because a nanosecond timestamp is about 1.8 × 1018 and JavaScript holds integers exactly only to 9,007,199,254,740,991. Anything past that is rounded silently, so Number('1787000000123456789') returns 1787000000123456800 — no error, just different digits.

Three bugs, one page

Timestamp conversion looks like division. It is, right up until one of these:

  1. Nanoseconds exceed what a JavaScript number can hold. Values get rounded with no error raised.
  2. The unit is guessed by counting digits. Ten digits only means seconds between September 2001 and November 2286.
  3. A date string means a different instant depending on whether it has a time in it. This one is specified behaviour, not a bug, which is why it never gets fixed.

All three fail quietly. None throws.

1. Nanoseconds do not fit

Number.MAX_SAFE_INTEGER        // 9007199254740991      ≈ 9.0e15
Number('1787000000123456789')  // 1787000000123456800   ≈ 1.8e18

A nanosecond timestamp today is roughly two hundred times larger than the biggest integer a double can represent exactly. Past that limit the gaps between representable values grow, and your value lands on the nearest one — here, 11 nanoseconds away.

No exception. No warning. The date is right to the millisecond and wrong in the digits you were looking at, which is the only reason you would be working in nanoseconds at all.

UnitNow, roughlyExact as a JS number?
Seconds1.79 × 109Yes
Milliseconds1.79 × 1012Yes
Microseconds1.79 × 1015Yes, until about the year 2255
Nanoseconds1.79 × 1018No

Go’s time.UnixNano, most tracing systems and several databases hand back nanoseconds, so this is not an exotic case.

The fix is to parse as BigInt and only narrow once the value is in milliseconds, where a double is exact for any date anyone will ever type:

const nanos = BigInt('1787000000123456789');   // exact
const ms = Number(nanos / 1_000_000n);         // 1787000000123 — safe

Beware the divide: BigInt division truncates towards zero, which is the wrong direction below the epoch. -1500n / 1000n is -1n, but −1500 ms is 1969-12-31T23:59:58.500, whose whole second is −2. Floor, do not truncate.

2. Counting digits is not detecting units

The usual heuristic is that ten digits means seconds and thirteen means milliseconds. It is right most of the time, which is what makes it dangerous — the failures are rare, silent, and land on ordinary dates.

Ten digits means seconds only from 9 September 2001 to 20 November 2286. Outside that window the rule is simply wrong:

ValueDigitsAs secondsAs milliseconds
10000000091973-03-031970-01-02
1787000000102026-08-171970-01-21
99999999999115138-11-161973-03-03

A digit rule reads the first row as milliseconds and confidently returns 2 January 1970 for a perfectly ordinary date in 1973 — and every second-precision timestamp from the first thirty-one years of Unix time is nine digits or fewer.

A better rule: try every unit and keep the reading that lands nearest today, discarding any that falls outside the range a date can represent. That single test correctly identifies all four units for a present-day value, and it degrades sensibly rather than catastrophically at the edges.

It needs one guard. Near the epoch every reading is about equally far from now — as seconds, -1 is one second before it; as milliseconds, one millisecond before it, which is nearer to today by 999 ms out of some 1.8 trillion. Let that decide and the most obvious test value anyone types comes back as milliseconds. Require a finer unit to win by a real margin, and it does not.

3. Adding a time changes the instant

This is the one that catches everybody, and it is required behaviour:

StringRead asIn Auckland (UTC+13)
2024-01-15Midnight UTC15 January, 1pm
2024-01-15T00:00:00Midnight local14 January, 11am UTC

ECMA-262 says a date-only form is UTC and a date-time form without an offset is local time. So adding a time component you thought was redundant shifts the instant by your whole offset — enough to change the day, and with it the month, the quarter and the financial year.

It cannot be fixed without breaking every page that relies on it. The only defence is to write the offset down: 2024-01-15T00:00:00Z means one instant everywhere on earth.

The week number that belongs to last year

Not a converter bug so much as a formatting one, but it ships every December and is noticed the following December.

ISO 8601 numbers a week by the year its Thursday falls in. So:

  • 1 January 2021 is 2020-W53-5
  • 1 January 2017 is 2016-W52-7
  • 31 December 2019 is 2020-W01-2

Printing an ISO week number beside the calendar year produces a label that does not exist for a few days each year. The week-numbering year is part of the week date and travels with it.

Leap seconds, and why Unix time ignores them

Unix time is defined as though every day contains exactly 86,400 seconds. The 27 leap seconds inserted since 1972 are absorbed by repeating a value rather than adding one.

So the count is not truly elapsed seconds since 1970 — it is about half a minute short. That is a deliberate trade: it means converting a timestamp to a date is arithmetic rather than a table lookup, and that every day is the same length. Both are worth more than the accuracy given up.

Dates that break software

ValueMomentWhat breaks
21474836472038-01-19T03:14:07ZA signed 32-bit counter overflows; one second later reads as December 1901
Anything negativeBefore 1970Software storing Unix time unsigned cannot represent it — which is how a 1965 birth date becomes 2106

Both are worth flagging in any tool that handles arbitrary input, because both produce a plausible-looking wrong answer rather than an error.

Checking a converter

Paste -1. The answer is 1969-12-31T23:59:59Z.

If it returns something in 1970, the unit detection is being decided by rounding noise. If it returns an error, it does not handle pre-epoch dates. If it returns 1969-12-31T23:59:59.999, it truncated towards zero instead of flooring.

Then paste 1787000000123456789 and check the last three digits survive.

The converter here counts in BigInt, picks the unit by plausibility rather than digit count, shows all four readings so the guess is visible, and states which time zone it had to assume whenever a string does not say. Its tests assert against Python’s datetime rather than against JavaScript’s own Date — checking a date library with the same runtime’s date library proves that two calls agree, which they would even if both were wrong.