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

Canonical URL: https://offlineutils.com/blog/nanosecond-timestamps-json-parse
Section: Engineering
Published: 2026-10-23
Publisher: offlineutils.com

> 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:

```js
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](https://offlineutils.com/glossary/floating-point): 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](https://offlineutils.com/glossary/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](https://datatracker.ietf.org/doc/html/rfc8259#section-6) 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](https://opentelemetry.io/docs/specs/otlp/) 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](https://discord.com/developers/docs/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:

```js
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`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/rawJSON) wraps a string of digits so that it is emitted unquoted, exactly as written:

```js
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](https://offlineutils.com/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](https://offlineutils.com/json-to-table), [CSV converter](https://offlineutils.com/csv-json) and [JWT decoder](https://offlineutils.com/jwt-decoder) use the reviver context to keep large numbers exact, and the [timestamp converter](https://offlineutils.com/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.
