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.
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:
| Unit | Value in October 2026 | Exceeds 2^53 | Gap between doubles today |
|---|---|---|---|
| Seconds | 1.79 × 10^9 | in about 285 million years | exact |
| Milliseconds | 1.79 × 10^12 | around the year 287,397 | exact |
| Microseconds | 1.79 × 10^15 | 5 June 2255 | exact |
| Nanoseconds | 1.79 × 10^18 | 15 April 1970 | 256 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.
| Measurement | Result |
|---|---|
| Timestamps parsed | 1,000,000 |
| Came back unchanged | 3,951 (0.395%) |
| Came back changed | 996,049 (99.605%) |
| Largest error | 128 ns |
| Mean error | 64.0 ns |
| The same instants as microseconds | 1,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:
| Parser | Result for 1753524000123456789 |
|---|---|
JavaScript JSON.parse (Node 26) | 1753524000123456800 — rounded |
Go 1.26 encoding/json into any | 1.7535240001234568e+18 — rounded |
Go 1.26 into an int64 field, or with UseNumber() | exact |
| jq 1.8.1, passing the value through | exact |
jq 1.8.1, after arithmetic (. + 0) | 1753524000123456800 — rounded |
Python 3.13 json.loads | exact |
Ruby 2.6 JSON.parse | exact |
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
Concepts in this post
Floating-point number
A floating-point number stores a value as a binary fraction scaled by a power of two. JavaScript's Number is an IEEE 754 double with 53 significant bits, so integers above 2^53 − 1 (9,007,199,254,740,991) and most decimal fractions, 0.1 included, cannot be stored exactly.
Unix Timestamp
A Unix timestamp is a single number counting the seconds elapsed since 1970-01-01T00:00:00Z, ignoring leap seconds — a time zone-free, language-agnostic way to name an exact instant, which is why it is what logs, databases, and APIs store internally.
JSON
JSON (JavaScript Object Notation) is a lightweight text format for structured data — built from objects, arrays, strings, numbers, booleans, and null — that's become the lingua franca of web APIs, configuration files, and data interchange between services.
Related posts
JSON formatter vs jq vs your editor: when to reach for which
Three tools cover almost every JSON task developers face — a browser-tab formatter, jq in the terminal, and the editor's built-in formatter. They overlap, but each one is meaningfully better for a specific kind of job. Here's how to pick.
Most punycode converters disagree with your browser. We measured it.
Punycode is only the last step of turning a Unicode domain into an xn-- label, and tools that stop there return a different domain than your address bar does. We tested a bare RFC 3492 implementation against the browser's own IDNA on seventeen realistic names: six came back wrong. Then we measured our own against ICU across 1,112,042 code points.