<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: xushoulin</title>
    <description>The latest articles on DEV Community by xushoulin (@xushoulin).</description>
    <link>https://dev.to/xushoulin</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4201742%2Fcc752346-916c-4221-b1d1-0239a382c4a9.jpg</url>
      <title>DEV Community: xushoulin</title>
      <link>https://dev.to/xushoulin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/xushoulin"/>
    <language>en</language>
    <item>
      <title>devto-post-1-precision</title>
      <dc:creator>xushoulin</dc:creator>
      <pubDate>Fri, 23 Oct 2026 06:04:21 +0000</pubDate>
      <link>https://dev.to/xushoulin/devto-post-1-precision-2j1j</link>
      <guid>https://dev.to/xushoulin/devto-post-1-precision-2j1j</guid>
      <description>&lt;p&gt;---&lt;br&gt;&lt;br&gt;
title: "\"Your 19-digit ID is a different number after JSON.parse\"  "&lt;br&gt;
published: false&lt;br&gt;&lt;br&gt;
description: "\"RFC 8259 puts no size limit on numbers. JavaScript has exactly one numeric type. What that costs, measured on Node 22.\"  "&lt;br&gt;
tags: javascript, json, webdev, api&lt;br&gt;&lt;br&gt;
canonical_url: &lt;a href="https://www.shoulin.shop/guides/why-json-numbers-lose-precision" rel="noopener noreferrer"&gt;https://www.shoulin.shop/guides/why-json-numbers-lose-precision&lt;/a&gt;&lt;br&gt;&lt;br&gt;
---&lt;/p&gt;

&lt;p&gt;A support ticket I keep seeing, in various disguises: "the ID changed after we formatted the JSON." It didn't change because of the formatter. It changed the moment the document was parsed, before any tool could intervene.&lt;/p&gt;

&lt;p&gt;Here is the whole failure in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"id":9007199254740993}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="c1"&gt;// 9007199254740992&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing was malformed. The container was too small.&lt;/p&gt;

&lt;h2&gt;
  
  
  The spec allows it; the runtime does not
&lt;/h2&gt;

&lt;p&gt;RFC 8259 does not put a size limit on numbers. It explicitly permits implementations to set limits on range and precision, and it warns that integers outside ±(2^53 − 1) "indicate potential interoperability problems."&lt;/p&gt;

&lt;p&gt;That sentence is the entire story. The format happily carries a 20-digit integer. The runtime almost everyone parses it with cannot hold one, because JavaScript has exactly one numeric type: a 64-bit float. Every number literal in a JSON document becomes a double before your code sees it.&lt;/p&gt;

&lt;p&gt;So the document is valid, the parser is correct, and the value is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the boundary actually is
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Number.MAX_SAFE_INTEGER&lt;/code&gt; is &lt;code&gt;9007199254740991&lt;/code&gt;, which is 2^53 − 1. An integer at or below that is represented exactly.&lt;/p&gt;

&lt;p&gt;The next integer, &lt;code&gt;9007199254740992&lt;/code&gt;, is 2^53 and is still exact — powers of two always are. But &lt;code&gt;9007199254740993&lt;/code&gt; does not exist as a double. It rounds &lt;strong&gt;down&lt;/strong&gt; to &lt;code&gt;9007199254740992&lt;/code&gt;, and so does every odd integer after it, until the next power of two where the spacing widens again.&lt;/p&gt;

&lt;p&gt;This is why the bug survives code review. An 18-digit snowflake ID sits comfortably inside the range of a 64-bit integer and comfortably outside the range of a double. The two ranges overlap just enough for everyone to assume they're the same range.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a round trip actually changes
&lt;/h2&gt;

&lt;p&gt;I ran the same document through &lt;code&gt;JSON.parse&lt;/code&gt; and then &lt;code&gt;JSON.stringify&lt;/code&gt; — which is exactly what "format" means — and recorded every difference.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;After round trip&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{"id":9007199254740993}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"id": 9007199254740992}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Numbers become IEEE-754 doubles. Integers above 2^53 lose their last digits.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{"b":1,"2":2,"1":3}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"1": 3, "2": 2, "b": 1}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keys that look like array indices are enumerated first, ascending. That is a JavaScript object rule, not a JSON one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{"a":1,"a":2}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"a": 2}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Duplicate member names collapse silently: the last value wins and the others are gone.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{"x":1.0,"y":1e3,"z":-0}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"x": 1, "y": 1000, "z": 0}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Numbers are re-printed in their shortest round-trippable form.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;"\u00e9\/"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"é/"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Escapes are decoded, then only the ones that must be escaped are written back.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the first row is real information loss. The other four are normalisation: the bytes differ, the meaning does not.&lt;/p&gt;

&lt;p&gt;That distinction matters more than it looks. If you diff two documents that have both been through a formatter, you will get a report of changes no human made — reordered keys, &lt;code&gt;1.0&lt;/code&gt; becoming &lt;code&gt;1&lt;/code&gt;, an escaped slash becoming a plain one. I built a &lt;a href="https://www.shoulin.shop/json-diff" rel="noopener noreferrer"&gt;JSON diff tool&lt;/a&gt; around this, and the "ignore key order" toggle exists precisely because of row two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Object key order is not insertion order
&lt;/h2&gt;

&lt;p&gt;Row two is the one people rarely expect. A JavaScript object enumerates integer-like keys first, in ascending numeric order, &lt;em&gt;then&lt;/em&gt; the remaining string keys in insertion order.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;{"b":1,"2":2,"1":3}&lt;/code&gt; therefore comes back as &lt;code&gt;{"1":3,"2":2,"b":1}&lt;/code&gt; — and no, that is not a bug in the formatter. JSON itself defines no order at all; it is a set of name/value pairs. Any consumer depending on member order was relying on undefined behaviour from the start.&lt;/p&gt;

&lt;p&gt;If order is part of your payload, put it in the data: an array of objects with an explicit &lt;code&gt;position&lt;/code&gt; field, not the order the bytes happened to arrive in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to keep big integers intact
&lt;/h2&gt;

&lt;p&gt;In order of how much you should prefer them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Make the field a string in the API.&lt;/strong&gt; This is what large platforms do. &lt;code&gt;{"id":"9007199254740993"}&lt;/code&gt; is unambiguous, survives every parser on every platform, and costs one pair of quotes. Nobody downstream has to remember a rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Intercept the parse with a reviver.&lt;/strong&gt; A reviver can inspect each value before your code sees it. Recent V8 engines pass a third argument that exposes the raw literal, which is what makes this reliable instead of guesswork:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// context.source is the literal exactly as written in the document.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isSafeInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/^-&lt;/span&gt;&lt;span class="se"&gt;?\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I verified this on Node 22.22.2 — &lt;code&gt;context.source&lt;/code&gt; really does hold &lt;code&gt;"9007199254740993"&lt;/code&gt; before the double conversion wins. But feature-detect it rather than trusting it: if &lt;code&gt;context&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, the engine predates the feature and the digits are already gone. In that case fall back to option 1 or 3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Use a lossless parser.&lt;/strong&gt; Libraries like &lt;code&gt;lossless-json&lt;/code&gt; keep the original text of each number and hand you a wrapper type. They work everywhere, at the cost of a dependency and a value type nobody on your team has seen before.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you hash or sign JSON
&lt;/h2&gt;

&lt;p&gt;A round trip is not byte-stable, so &lt;strong&gt;never re-serialise a payload that was signed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two documents can be semantically identical and hash differently because of whitespace, key order, or number formatting — &lt;code&gt;1.0&lt;/code&gt; and &lt;code&gt;1&lt;/code&gt; are the same float and different bytes. A payload that went through &lt;code&gt;JSON.parse&lt;/code&gt; and back may no longer match its own signature, and the failure looks like a corrupted message rather than a formatting difference.&lt;/p&gt;

&lt;p&gt;Sign the exact bytes you send: serialise once, hash that string, transmit the same string. On the receiving side, verify before parsing, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;JSON allows numbers of any length; JavaScript has one numeric type, and it's a float.&lt;/li&gt;
&lt;li&gt;The safe range is ±(2^53 − 1). Snowflake IDs sit just outside it, which is why this bites production systems and not tutorials.&lt;/li&gt;
&lt;li&gt;Strings for IDs. Always.&lt;/li&gt;
&lt;li&gt;A formatter can't fix this, because the damage happens inside the parse. Any tool claiming to preserve &lt;code&gt;9007199254740993&lt;/code&gt; as a number is lying about what the runtime holds afterwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full version, with the same measurements and a few more cases, is at &lt;a href="https://www.shoulin.shop/guides/why-json-numbers-lose-precision" rel="noopener noreferrer"&gt;shoulin.shop/guides/why-json-numbers-lose-precision&lt;/a&gt;. If you want to watch the loss happen to your own payload, &lt;a href="https://www.shoulin.shop/json-formatter" rel="noopener noreferrer"&gt;the formatter&lt;/a&gt; runs entirely in the tab — no upload, no account, and the Network tab will show you nothing leaving.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
