DEV Community

Super Funicular
Super Funicular

Posted on

Two Files 100 Nanoseconds Apart Got the Same Timestamp in the Browser: JSON.parse and Integers Past MAX_SAFE_INTEGER

A small Python server lists a folder of clips as JSON, with each file's modification time in nanoseconds. The browser page that reads that list decided two of the three files had the same timestamp, and when it sent one timestamp back to ask whether that file had changed since the listing, the server answered yes. Nothing had changed. The numbers were simply too large for JSON.parse to hold.

Disclosure: I make an Android camera app. Nothing below uses its code or describes how it lists, names or stores files. Everything here ran on one Linux machine (kernel 6.8, Python 3.10.12, Node.js v22.23.2) against files the scripts create themselves.

What the tests showed:

  • Python wrote the timestamps exactly; Node's JSON.parse rounded two of them. Two files that were modified 100 nanoseconds apart came out with the same value.
  • The rounded value round-tripped as a different number. The page sent back a timestamp the server had not written, so an only if unchanged check failed.
  • A reviver can see the original digits. In Node 22 the third reviver argument, context.source, holds the exact text, so the value can be rebuilt as a BigInt.
  • Sending a BigInt back needs one more step. JSON.stringify throws on a BigInt; JSON.rawJSON writes the digits out unchanged.

The setup

The script below makes three empty files and sets their modification times 100 nanoseconds apart with os.utime(ns=...), so the result is the same on every run. Real files rarely land this close together; the gap makes the effect certain.

# Build a folder of three files whose modification times are 100 ns apart,
# then print the JSON a small Python server might return for it.
import json, os, tempfile

d = tempfile.mkdtemp()
base = 1792467605_000000000          # a nanosecond timestamp from this week
for i, name in enumerate(["a.mp4", "b.mp4", "c.mp4"]):
    p = os.path.join(d, name)
    open(p, "wb").close()
    os.utime(p, ns=(base + i * 100, base + i * 100))

clips = [{"name": n, "mtime_ns": os.stat(os.path.join(d, n)).st_mtime_ns}
         for n in sorted(os.listdir(d))]
print(json.dumps(clips))
Enter fullscreen mode Exit fullscreen mode

Run as python3 list_clips.py > clips.json, its output is ordinary JSON. Python's json module writes integers of any size digit for digit:

[{"name": "a.mp4", "mtime_ns": 1792467605000000000}, {"name": "b.mp4", "mtime_ns": 1792467605000000100}, {"name": "c.mp4", "mtime_ns": 1792467605000000200}]
Enter fullscreen mode Exit fullscreen mode

What the browser side sees

Node's JSON.parse uses the same number type as a browser. This reads the list and compares the values:

const text = require("fs").readFileSync("clips.json", "utf8");
const clips = JSON.parse(text);
for (const c of clips) console.log(c.name, c.mtime_ns, Number.isSafeInteger(c.mtime_ns));
console.log("a === b:", clips[0].mtime_ns === clips[1].mtime_ns);
console.log("b === c:", clips[1].mtime_ns === clips[2].mtime_ns);
Enter fullscreen mode Exit fullscreen mode
a.mp4 1792467605000000000 false
b.mp4 1792467605000000000 false
c.mp4 1792467605000000300 false
a === b: true
b === c: false
Enter fullscreen mode Exit fullscreen mode

All three values fail Number.isSafeInteger. The limit is Number.MAX_SAFE_INTEGER, which is 9007199254740991, and MDN's page on it defines the word:

"Safe" in this context refers to the ability to represent integers exactly and to compare them correctly.

A nanosecond timestamp from this year is about two hundred times larger.

How far off can a value be? Python's math.ulp gives the distance between neighbouring doubles at that size:

import math
t = 1792467605000000000
print("2**60 <", t, "< 2**61:", 2**60 < t < 2**61)
print("gap between neighbouring doubles here:", math.ulp(float(t)))
print("exact value the browser holds for c.mp4:", int(float(1792467605000000200)))
Enter fullscreen mode Exit fullscreen mode
2**60 < 1792467605000000000 < 2**61: True
gap between neighbouring doubles here: 256.0
exact value the browser holds for c.mp4: 1792467605000000256
Enter fullscreen mode Exit fullscreen mode

At this magnitude the gap between representable values is 256 nanoseconds. The value for b.mp4 rounded down onto a.mp4. The value for c.mp4 became 1792467605000000256, and Node prints that as 1792467605000000300, the shortest decimal that maps back to the same double. Neither printed number is one the server ever wrote.

The round trip that broke

The page then asks the server to act on c.mp4 only if it has not changed since the listing. It sends back the timestamp it holds:

// The page asks the server to delete c.mp4 "only if it has not changed since I listed it".
const clips = JSON.parse(require("fs").readFileSync("clips.json", "utf8"));
const c = clips.find(x => x.name === "c.mp4");
console.log(JSON.stringify({ name: c.name, if_mtime_ns: c.mtime_ns }));
Enter fullscreen mode Exit fullscreen mode

Run as node echo_back.js > request.json, that sends if_mtime_ns as 1792467605000000300. The server compares:

import json
clips = {c["name"]: c for c in json.load(open("clips.json"))}
req = json.load(open("request.json"))
have = clips[req["name"]]["mtime_ns"]
print("server has ", have)
print("page sent  ", req["if_mtime_ns"])
print("unchanged? ", have == req["if_mtime_ns"])
Enter fullscreen mode Exit fullscreen mode
server has  1792467605000000200
page sent   1792467605000000300
unchanged?  False
Enter fullscreen mode Exit fullscreen mode

The file did not change. The check failed because the page could not hold the number it was asked to repeat. Turned around, the same rounding can make a check pass that should fail: a.mp4 and b.mp4 hold the same value on the page, so a page-side newer-than test cannot tell them apart.

MDN's JSON.parse page puts the cause plainly: the reviver "is run after the value is parsed. So, for example, numbers in JSON text will have already been converted to JavaScript numbers, and may lose precision in the process."

Seeing which values were damaged

The same page documents a third reviver argument: "For primitive values, the context argument has a field called source containing the source text of this value." Node 22 passes it. That makes a cheap audit possible: compare the source text with what the parser produced.

// Flag every integer that JSON.parse could not hold exactly.
const text = require("fs").readFileSync("clips.json", "utf8");
JSON.parse(text, (key, value, context) => {
  if (typeof value === "number" && !Number.isSafeInteger(value) && Number.isInteger(value)) {
    const lost = context.source !== String(value);
    console.log(key, "source", context.source, "->", value, lost ? "(changed)" : "(happens to be exact)");
  }
  return value;
});
Enter fullscreen mode Exit fullscreen mode
mtime_ns source 1792467605000000000 -> 1792467605000000000 (happens to be exact)
mtime_ns source 1792467605000000100 -> 1792467605000000000 (changed)
mtime_ns source 1792467605000000200 -> 1792467605000000300 (changed)
Enter fullscreen mode Exit fullscreen mode

One unsafe value survived intact, because 1792467605000000000 happens to be a multiple of 256. That is the trap with spot checks. A test on a round number can pass while the neighbours fail, so the reliable signal is Number.isSafeInteger, not whether one sample looks right.

Keeping the digits with a BigInt

Rather than ignore the parsed number, the reviver can rebuild the field from its source text, which is the pattern MDN shows with BigInt(context.source):

// Keep the digits: revive mtime_ns from its source text as a BigInt.
const text = require("fs").readFileSync("clips.json", "utf8");
const clips = JSON.parse(text, (key, value, context) =>
  key === "mtime_ns" ? BigInt(context.source) : value);
for (const c of clips) console.log(c.name, c.mtime_ns);
console.log("a === b:", clips[0].mtime_ns === clips[1].mtime_ns);
const c = clips[2];
try {
  console.log(JSON.stringify({ name: c.name, if_mtime_ns: c.mtime_ns }));
} catch (e) {
  console.log(e.constructor.name + ": " + e.message);
}
const body = JSON.stringify({ name: c.name, if_mtime_ns: c.mtime_ns }, (k, v) =>
  typeof v === "bigint" ? JSON.rawJSON(v.toString()) : v);
console.log(body);
require("fs").writeFileSync("request.json", body);
Enter fullscreen mode Exit fullscreen mode
a.mp4 1792467605000000000n
b.mp4 1792467605000000100n
c.mp4 1792467605000000200n
a === b: false
TypeError: Do not know how to serialize a BigInt
{"name":"c.mp4","if_mtime_ns":1792467605000000200}
Enter fullscreen mode Exit fullscreen mode

Three results in that output:

  1. All three timestamps are exact, and a.mp4 and b.mp4 are distinct again.
  2. Plain JSON.stringify on an object holding a BigInt throws TypeError: Do not know how to serialize a BigInt. The page would fail at the moment it tried to send.
  3. A replacer that wraps each BigInt in JSON.rawJSON writes the digits as a bare JSON number. MDN describes the purpose of JSON.rawJSON in those terms: "The most typical use case is the floating point number loss of precision problem."

With the fixed request in place, the server check passes:

server has  1792467605000000200
page sent   1792467605000000200
unchanged?  True
Enter fullscreen mode Exit fullscreen mode

Support is the cost of this route. MDN lists JSON.rawJSON as Baseline 2025 and says "Since March 2025, this feature works across the latest devices and browser versions. This feature might not work in older devices or browsers." The JSON.parse page carries a similar note for the reviver's newer parts: "Some parts of this feature may have varying levels of support." A page meant for an old tablet cannot assume either.

The two older escapes

Send the field as a string. MDN's own suggestion on the JSON.parse page is "to serialize them as strings, and revive them to BigInts, or other appropriate arbitrary precision formats." A string survives any JSON parser, old or new. The catch is ordering:

// If the server sends the field as a string, the digits survive any parser.
const clips = JSON.parse('[{"name":"a.mp4","mtime_ns":"1792467605000000000"},' +
                         '{"name":"b.mp4","mtime_ns":"1792467605000000100"}]');
console.log("a === b:", clips[0].mtime_ns === clips[1].mtime_ns);
// Ordering is the catch: strings compare character by character.
console.log('"999" < "1000":', "999" < "1000");
console.log("as BigInt:", BigInt("999") < BigInt("1000"));
Enter fullscreen mode Exit fullscreen mode
a === b: false
"999" < "1000": false
as BigInt: true
Enter fullscreen mode Exit fullscreen mode

Equality on strings is exact. Ordering is not, once the strings differ in length. Nanosecond timestamps from the same era all have the same number of digits, so a plain string sort works on them for now, but an ID field that grows from 999 to 1000 will sort wrong. Convert to BigInt before comparing for order.

Send whole milliseconds. Dividing by a million brings the value well under the safe limit, and every JSON parser handles it:

# Dropping to whole milliseconds keeps the number safe, and loses the order.
import json
clips = json.load(open("clips.json"))
for c in clips:
    ms = c["mtime_ns"] // 1_000_000
    print(c["name"], ms, ms <= 2**53 - 1)
Enter fullscreen mode Exit fullscreen mode
a.mp4 1792467605000 True
b.mp4 1792467605000 True
c.mp4 1792467605000 True
Enter fullscreen mode Exit fullscreen mode

The number is safe and all three files now share it. For a gallery that sorts by date, that is usually fine. For an only if unchanged check, it means a file rewritten within the same millisecond looks unchanged, which is the failure the check exists to catch. If a server needs exact change detection, a millisecond timestamp alone is the wrong field. A content hash or a version counter sent as a string avoids the question.

What I would do with this

  1. On the server, send any integer that can exceed the safe limit as a string. Nanosecond times, 64-bit IDs and byte offsets into very large files are the usual suspects. This works for every client.
  2. On the page, convert those strings to BigInt where order matters, and keep them as strings everywhere else.
  3. If the API already sends bare numbers and cannot change, revive the affected fields with BigInt(context.source) and send them back with JSON.rawJSON, after checking that every browser the page has to support has both.
  4. Add the Number.isSafeInteger audit from guard.js to a test that parses a real response. It fails loudly on the first unsafe integer.

Things I did not test

  • Browsers. Everything ran in Node.js v22.23.2. Browsers share the number type, so the rounding is the same, but I did not check which versions pass context.source.
  • Other servers. Only Python's json module produced the JSON here. Other languages and libraries may write large integers differently, including as floats.
  • Real recording folders. The timestamps were set by hand to be 100 nanoseconds apart. How close real files get depends on the filesystem's timestamp resolution, which I did not measure.

Sources. Quotes are from MDN Web Docs: JSON.parse(), Number.MAX_SAFE_INTEGER and JSON.rawJSON(). Test results come from the scripts above, run once each in order in one folder, with the output pasted unedited.

If you want an old phone to record clips in the background so there is a folder worth listing, the app I make is Background Camera RemoteStream on Google Play. Nothing above depends on it.

If the other side cannot hold a number exactly, send it as a string.

Top comments (0)