2026-10-24 · 8 min read

4.0 Is Not 4: The JSON Round Trip That Quietly Rewrites Your Data

Flat isometric diagram of a document splitting into two paths: smooth indigo spheres passing through a gate unchanged and angular violet shards routed around it into a tray, rejoining at one green node.

Open a browser console and paste this: JSON.parse('{"id": 9007199254740993}').id. You get 9007199254740992. Off by one, no error, no warning, no truncation notice. The value you asked for is gone, and something that looks almost exactly like it is standing where it was.

This is not a bug in JSON.parse. It is doing exactly what the spec allows. That is what makes it dangerous: JSON has one numeric type with no size limit, JavaScript has one numeric type with 53 bits of integer precision, and the standard's choice when those two meet is to lose data silently. Any program that reads JSON, touches it, and writes it back is a program that can quietly change your data. Most of them do, and most of them do not know it.

If you take one thing from this piece: the famous failure — big integers rounding — is not the most common one. It is just the only one that produces a number that looks visibly wrong.

The three ways it bites

1. Identity fields round

JavaScript numbers are IEEE-754 doubles. They hold integers exactly up to Number.MAX_SAFE_INTEGER, which is 9007199254740991 (2^53 minus 1). Past that, consecutive integers stop being representable. Parse {"id": 1071004833782874112} — a Discord-style snowflake — and you get back 1071004833782874100. The last two digits are invented.

This is worse than a crash, because a rounded price is obviously wrong and a rounded ID is a valid-looking ID that points at nothing or, worse, at something else. Snowflakes, social-media post IDs, database bigints, and a lot of billing identifiers all pack a timestamp into a 64-bit integer, which is exactly the range where doubles give up.

2. Overflow changes the type

Parse a value too large for a double and you get Infinity. Write it back out and JSON.stringify turns Infinity into null, because JSON has no literal for it. So a round trip converts a number into a null. Downstream that is not a precision problem, it is a type change: the field that meant "a very large number" now means "no value."

And it is not only a parser artifact. Any NaN or Infinity produced by your own arithmetic — a divide by zero in a metrics rollup, an average over an empty set — becomes null on the way out. Your consumer sees a missing value and treats it as optional, when what you meant was "this computation failed."

3. Formatting dies even when the value survives

JSON.stringify(JSON.parse('{"v": 4.0}')) gives you {"v":4}. 4.0 and 4 are the same number. They are not the same document. The same happens to 1.00, to 5e+3 becoming 5000, and to -0 becoming 0.

If you round-trip a config file, a fixture, an API contract, or anything a human wrote and will read again, that is a diff. It shows up in code review and in git blame. A formatter that "cleaned up" a checked-in JSON file is a classic source of noise commits, and every one of them costs a reviewer's attention.

Why nothing screams

No exception, no log line, no warning. The document parses, the program runs, the tests pass — because the fixtures were generated by the same broken round trip. You find out when a customer reports that a record will not load and the ID in your logs is one digit off from the ID in theirs.

What people reach for, and where each stops

  • Parse to BigInt. Fixes large integers. Does nothing for 4.0 or for Infinity, and gives you a type that will not stringify without a custom replacer.
  • Parse all numbers as strings. Safe. Breaks every consumer that expected a number, and pushes the conversion problem one layer down without solving it.
  • Ask the API for string IDs. Correct, and most public APIs already do this for exactly this reason. Not available to you when you are looking at somebody else's payload.

All three treat it as a number problem. It is a serialization problem.

Preserve the token, not the value

The framing that actually works: JSON numbers arrive as tokens — strings of characters such as 4.0, 1e400, or 1071004833782874112. Your parser's job is to decide, per token, whether you can safely collapse it into a machine number. Ask one question per numeric token: would writing this back out produce the exact characters I read?

If yes, keep it as an ordinary number. If no, keep the original digits and carry them through untouched. The lossless-json library gives you the wrapper type and the stringify side; the policy is yours, and the policy is the part worth copying:

if (isSafeNumber(value) && String(parseFloat(value)) === value) { return parseFloat(value) }

return parseLosslessNumber(value)

Two conditions, because there are two independent failure modes. isSafeNumber catches value loss, and it does not catch formatting loss — it says 4.0 is perfectly safe, because numerically it is. That is why the second check exists: String(parseFloat("4.0")) is "4", which is not "4.0", so the token gets preserved even though nothing about its value was ever at risk. Checking only the first condition is the natural implementation, and it silently reformats every document you run through it.

The other half of the design matters just as much: most numbers are not wrapped. 1, 42, 0.1 and -17.5 all round-trip exactly and stay plain JavaScript numbers, so the hot path pays almost nothing.

Where to put the boundary

You do not need this everywhere. You need it wherever a value will be written back out or compared against something another system considers identical. Three tiers:

  1. Read, decide, discard. A webhook you validate and normalize into your own storage. Parse freely — but make sure the normalized record stores identity fields as strings, or you have just laundered the double into your database.
  2. Round-trip tooling. Formatters, config editors, migration scripts, proxies that rewrite JSON, anything that hands a document back to a human or to git. Token preservation is mandatory here; byte equality is the whole contract.
  3. Contracts and storage. IDs, money, timestamps, hashes. Never let a bare JSON number carry identity. String IDs, decimal strings for money, ISO strings for time. The spec is not going to protect you.

The one question that sorts a value into a tier: will this exact value be written back out, or compared to a copy someone else holds? If yes, preserve the token.

What it costs

Be honest about the trade-offs. A wrapper type leaks: typeof x === "number" stops being true, arithmetic needs an unwrap, and schema validators that have never seen a wrapped numeric token will reject it. You will need a custom stringify and a rule for when values get unwrapped. For a read-only analytics path where the numbers are consumed and thrown away, all of this is overhead you should skip. The point is to make the choice per boundary, not to wrap your whole stack in suspicion.

How to check whether you are affected, in ten minutes

  • The golden byte test. For every fixture, assert that stringify(parse(text)) === text, exactly. Not deep-equal — byte-equal. Deep equality will happily pass while the document changes.
  • Plant canaries. Add a fixture with an ID ending in an odd digit past the precision limit, a 4.0, a 1e400, and a -0. One round trip tells you everything.
  • Commit bytes, not objects. If your fixtures are generated by serializing an in-memory object, they were produced by the same round trip that has the bug. Generate them once from the raw source and commit the text.
  • Watch the diffs. In CI, flag pull requests whose only change to a JSON file is numeric formatting. That is this bug, caught in review.

The hiring version

This is a good interview question, and a better one than most. Ask a candidate what JSON.parse('{"id": 9007199254740993}') returns, then ask what they would change in a system where that value is an account identifier. People who have debugged a real "record not found" against an ID that looks right will answer immediately. People who have not will reason their way toward it, and how they reason tells you more than the answer: do they reach for BigInt and stop there, or do they ask what happens on the way back out?

It also names a class of bug worth raising in design reviews: our serialization layer is not identity-preserving. That class covers YAML timestamps that turn into dates, CSV quote handling, floating-point money, and every "we just pass the payload through" service you own. JSON is simply the one you touch daily.

Key Takeaways

  • JSON.parse maps an unbounded number grammar onto a 53-bit type and loses data silently in three ways: rounding, overflow-to-null, and destroyed formatting.
  • The famous failure (big integers) is not the common one. Formatting loss and Infinity-to-null hit ordinary documents with ordinary numbers.
  • Ask one question per numeric token: would writing this back out reproduce the exact characters? If yes, keep the number; if no, keep the digits.
  • Check both conditions — value safety and round-trip string equality. Checking only value safety silently reformats every file you touch.
  • Wrap only what needs wrapping. Most numbers round-trip exactly and should stay plain numbers, which keeps the cost near zero.
  • Put the boundary at round-trip tooling and at contracts for IDs, money, and timestamps. Never let a bare JSON number carry identity.
  • Test with byte equality and planted canaries. Deep-equal tests pass while the document changes underneath them.

[ Call to action ]

Ready to scale your engineering team?

Tell us the roles you need to fill and we'll get back within 24 hours.