Engineering
8 min read·Published

Nanosecond timestamps don't survive JSON.parse — 99.6% come back changed

JavaScript reads every JSON number as an IEEE 754 double, and a nanosecond timestamp is too big for one: today, neighbouring doubles are 256 ns apart. We parsed a million random 2026 timestamps and 996,049 came back changed — by up to 128 ns, with no error. Here is why, which parsers are affected, and four ways to keep every digit.

By offlineutils.com

Paste this into any browser console:

JSON.stringify(JSON.parse("1753524000123456789"))
// '1753524000123456800'

The input is a nanosecond Unix timestamp: 26 July 2025, 10:00:00.123456789 UTC. The output is a different instant, 11 nanoseconds later. Stranger still, the printed digits are not even the value JavaScript stored — the Number holds 1753524000123456768, 21 nanoseconds earlier, and JSON.stringify prints the shortest decimal that maps back to it. There is no error and no warning, and the result has the right number of digits, so nothing downstream notices.

We measured how often this happens to real timestamps. The answer is almost always.

Why: a double has 53 bits

JSON itself puts no limit on the size of a number. But JSON.parse has to turn each one into a JavaScript Number, which is an IEEE 754 double: 52 stored fraction bits plus an implicit leading one, 53 significant bits in all. Every integer up to 2^53 — 9,007,199,254,740,992 — is exact. Above that, the gap between representable values doubles at every power of two, and the parser rounds to the nearest one.

Nanosecond timestamps crossed 2^53 on 15 April 1970, 104 days after the epoch. Every one since has been rounded; the only question is how coarsely. Between 14 July 2006 and 25 January 2043 they lie between 2^60 and 2^61, where neighbouring doubles are 256 ns apart:

UnitValue in October 2026Exceeds 2^53Gap between doubles today
Seconds1.79 × 10^9in about 285 million yearsexact
Milliseconds1.79 × 10^12around the year 287,397exact
Microseconds1.79 × 10^155 June 2255exact
Nanoseconds1.79 × 10^1815 April 1970256 ns

Milliseconds — what Date.now()returns — and microseconds are safe for centuries. Nanoseconds, the resolution of Go's UnixNano()and of OpenTelemetry's timestamps, have been unsafe for practically the whole history of the Unix timestamp.

What we measured

We generated a million nanosecond timestamps spread uniformly across 2026 with a seeded generator, ran each through JSON.parse in Node 26 — the V8 engine Chrome uses — and compared the result with the original digits using BigInt. ECMAScript requires the correctly rounded result for any number of up to 20 significant digits, which covers every 64-bit integer, so every conforming engine produces the same numbers.

MeasurementResult
Timestamps parsed1,000,000
Came back unchanged3,951 (0.395%)
Came back changed996,049 (99.605%)
Largest error128 ns
Mean error64.0 ns
The same instants as microseconds1,000,000 unchanged

That is exactly what the arithmetic predicts. A timestamp survives only if it happens to be a multiple of 256, and one in 256 are: 0.391%. Everything else lands on the nearest multiple, off by up to half the gap.

The error is small; what it does to data is not. We parsed 10,000 events spaced 100 ns apart — one millisecond of a busy service's event stream — and got back 3,907 distinct timestamps. 6,093 events now share a timestamp with a neighbour. Rounding never reverses the order of two events — it is monotonic, and we counted zero inversions — but it makes distinct events simultaneous, so deduplicating by timestamp drops real events and a join on timestamp matches the wrong rows. IDs suffer the same way: Snowflake IDs, Twitter's format that Discord also uses, are 64-bit integers of the same magnitude, so two different IDs can parse to the same Number.

It isn't only JavaScript

Any parser that reads JSON numbers into doubles does this. We fed the same number to the others we had to hand:

ParserResult for 1753524000123456789
JavaScript JSON.parse (Node 26)1753524000123456800 — rounded
Go 1.26 encoding/json into any1.7535240001234568e+18 — rounded
Go 1.26 into an int64 field, or with UseNumber()exact
jq 1.8.1, passing the value throughexact
jq 1.8.1, after arithmetic (. + 0)1753524000123456800 — rounded
Python 3.13 json.loadsexact
Ruby 2.6 JSON.parseexact

Python and Ruby read integers into arbitrary-precision types, so they keep every digit — until the value reaches a JavaScript client. Go is exact with a typed struct and silently lossy with the convenient map[string]any. jq keeps the literal you wrote, but the moment you do arithmetic on it, it is a double. RFC 8259 anticipates all of this: it notes that implementations will agree exactly only on integers within ±(2^53 − 1), and that the way to good interoperability is to expect no more precision than a double provides.

Why the big APIs send strings

This is why APIs that carry 64-bit values put them in quotes. OpenTelemetry's protocol specification says that, following Protobuf's JSON mapping, 64-bit integers in JSON payloads are encoded as decimal strings, and that this “also applies to timestamp fields (such as timeUnixNano…)”. Discord's API reference says its Snowflake IDs “are always returned as strings in the HTTP API to prevent integer overflows in some languages.”

One detail to watch: OTLP decoders must also accept plain numbers, so a sender that writes timeUnixNano unquoted is still valid — and a JavaScript receiver still rounds it. Be strict in what you send.

Four ways to keep every digit

1. Send 64-bit values as strings

The simplest and most portable fix, and the one OpenTelemetry and Discord chose. A string survives every JSON parser in every language, and the consumer converts it with BigInt(), or its language's 64-bit integer type, when it needs to do arithmetic.

2. Use microseconds if you don't need nanoseconds

A microsecond timestamp stays below 2^53 until 5 June 2255, and every one of our million 2026 instants survived as microseconds. If nothing reads the last three digits, dropping them loses nothing you were using.

3. Parse with access to the source text

Modern engines pass a reviver a third argument holding each number's original text, so exact values can be rebuilt as BigInt:

const data = JSON.parse(text, (key, value, context) =>
  typeof value === "number" &&
  !Number.isSafeInteger(value) &&
  /^-?\d+$/.test(context?.source)
    ? BigInt(context.source)
    : value,
);

The integer check matters: 1.5 and 1e20 are not safe integers either, and BigInt() throws on both. The context argument is available in Chrome 114, Firefox 135, Safari 18.4 and Node 21 onwards. On older engines it is undefined, the test fails, and you get the old rounded value rather than an exception.

4. Write exact values back with JSON.rawJSON

JSON.stringify throws a TypeError on a BigInt. JSON.rawJSON wraps a string of digits so that it is emitted unquoted, exactly as written:

JSON.stringify({ t: 1753524000123456789n }, (key, value) =>
  typeof value === "bigint" ? JSON.rawJSON(value.toString()) : value,
);
// '{"t":1753524000123456789}'

It has the same browser support as the reviver context. Our JSON formatter sidesteps the problem entirely — it re-emits the original digits rather than converting them — and flags any integer JavaScript would round, so you can see the problem before it reaches your code. The JSON to table, CSV converter and JWT decoder use the reviver context to keep large numbers exact, and the timestamp converter handles nanoseconds with BigInt throughout.

The rule of thumb

If a number in your JSON can exceed 9,007,199,254,740,991 — 64-bit IDs, nanosecond timestamps, 64-bit counters — send it as a string, and assume at least one consumer is JavaScript. The error is never loud. It averages 64 nanoseconds, it has the right number of digits, and it is a different value.

Frequently asked questions

Why does JSON.parse change my nanosecond timestamp?

Because JSON.parse turns every number into an IEEE 754 double, which holds 53 significant bits, and a nanosecond timestamp needs more: today's values are around 1.79 × 10^18, far above 2^53. At that size neighbouring doubles are 256 ns apart, so the parser rounds to the nearest multiple of 256. In our test of a million random 2026 timestamps, 99.6% came back changed, by up to 128 ns and 64 ns on average, without any error.

Are millisecond and microsecond timestamps safe in JSON?

Yes. Millisecond timestamps, which Date.now() returns, stay below 2^53 for roughly 285,000 years, and microsecond timestamps until 5 June 2255; all million of our 2026 test instants survived JSON.parse exactly as microseconds. Nanosecond timestamps crossed 2^53 on 15 April 1970, 104 days after the Unix epoch, so every one since has been at risk.

How do I parse large integers from JSON in JavaScript without losing precision?

Use the reviver's third argument, which holds the number's original source text, and convert unsafe integers with BigInt(context.source). Check that the source is a plain integer first, because BigInt() throws on values like 1.5 or 1e20. The context argument is available in Chrome 114, Firefox 135, Safari 18.4 and Node 21 onwards. Alternatively, have the sender transmit the value as a string, which works in every parser.

How do I write a BigInt back into JSON?

JSON.stringify throws a TypeError on a BigInt. Return JSON.rawJSON(value.toString()) from a replacer instead: it emits the digits unquoted, exactly as written, so {"t":1753524000123456789} round-trips intact. It has the same browser support as the reviver context. If the consumer might be an older engine, serialise the value as a string instead.

Does this happen in Python, Go or jq?

It depends on how the parser stores numbers. Python's json.loads and Ruby's JSON.parse kept 1753524000123456789 exact in our tests, because they read integers into arbitrary-precision types. Go 1.26 rounded it when decoding into map[string]any, which uses float64, but kept it exact in an int64 field or with Decoder.UseNumber(). jq 1.8.1 passed the literal through unchanged but rounded it as soon as we did arithmetic on it.

Why does OpenTelemetry send timeUnixNano as a string?

Because OTLP's JSON encoding follows Protobuf's JSON mapping, which encodes every 64-bit integer as a decimal string, and the OTLP specification states that this applies to timestamp fields such as timeUnixNano. A nanosecond timestamp sent as a JSON number would be rounded by any JavaScript receiver. Note that OTLP decoders also accept plain numbers, so a sender that leaves the quotes off is still valid and still loses precision.

Tools mentioned in this post

JSON Formatter

Format, validate and minify JSON entirely in your browser.

Timestamp Converter

Convert Unix time in any unit — seconds to nanoseconds — with exact precision.

Number Base Converter

Convert numbers between binary, octal, decimal, hex and any base.

JSON to Table Viewer

Render any JSON payload as a sortable, searchable HTML table.

JWT Decoder

Decode and inspect JSON Web Token claims without sending them anywhere.

Concepts in this post

Related posts