I always thought JSON was pretty straightforward.
You have a JavaScript object:
javascriptconst user = { name: "Elias Void", age: 22 }
Then:
javascriptJSON.stringify(user)
gives you JSON.
And when you need it back:
javascriptJSON.parse(json)
Done.
At least, that's how I used to think about it.
Then I started looking into what actually happens when JavaScript objects cross a JSON boundary, and I realized something pretty important:
JSON isn't a copy of your JavaScript object.
It's a completely different data format.
And once I understood that, a lot of weird JSON behavior suddenly started making sense.
The First Thing That Caught Me: Numbers
I came across this:
javascriptconst id = 9007199254740993 console.log(id) // 9007199254740992
Wait, what?
I literally wrote ...993, but JavaScript gives me ...992.
The important part is that JSON isn't even involved yet.
JavaScript's Number type can't represent every integer once you go beyond:
javascriptNumber.MAX_SAFE_INTEGER // 9007199254740991
So around this range, you start getting gaps:
text9007199254740992 ✓ 9007199254740993 ✗ 9007199254740994 ✓ 9007199254740995 ✗
There are gaps.
That immediately made me think about IDs.
Imagine a backend sends:
json{ "id": 9007199254740993 }
The JSON text contains the correct number.
But when JavaScript parses it:
javascriptJSON.parse(...)
it turns that number into a JavaScript Number, and the precision can already be gone.
That's why large IDs are often better represented as:
json{ "id": "9007199254740993" }
If I need arithmetic, I can turn that string into a BigInt.
So the first lesson was:
Don't assume that because JSON says "number", JavaScript can safely represent every number.
And that led me to another question:
If JSON has fewer rules than JavaScript, what happens to values that JSON doesn't even understand?
Then I Ran Into undefined
JavaScript has:
javascript{ nickname: undefined }
But JSON doesn't have an undefined value.
So:
javascriptJSON.stringify({ nickname: undefined })
becomes:
json{}
The property just disappears.
At first, this doesn't seem like a big deal.
But think about an update API.
Maybe:
json{}
means:
Don't change the nickname.
And:
json{ "nickname": null }
means:
Clear the nickname.
Now:
javascript{ nickname: undefined }
also becomes:
json{}
So the receiver can't tell what you originally meant.
That's when I started seeing the bigger pattern.
JSON isn't preserving JavaScript semantics. It's converting them into whatever JSON can represent.
And Dates make this even more obvious.
A Date Isn't a Date Anymore
Take:
javascriptconst createdAt = new Date("2026-07-21T12:00:00Z")
If I stringify it:
javascriptJSON.stringify({ createdAt })
I get:
json{ "createdAt": "2026-07-21T12:00:00.000Z" }
Looks fine.
But look at it after parsing:
javascriptconst copy = JSON.parse(json) copy.createdAt instanceof Date // false
It's just a string now.
JSON doesn't have a Date type.
And this is where I realized something important:
The data survived, but the type didn't.
The same thing happens with other JavaScript values.
For example:
javascriptNaN Infinity -Infinity
all become:
jsonnull
And things like:
javascriptMap Set RegExp
can lose their useful state and end up as plain objects.
So now we have another problem.
Not only can JSON change values, it can also erase the type that gave those values meaning.
Then I Found Out JSON.stringify() Can Actually Run Code
This one surprised me.
I used to imagine:
textobject ↓ JSON.stringify() ↓ string
as a simple conversion.
But objects can define toJSON().
For example:
javascriptconst user = { name: "Alice", password: "secret", toJSON() { return { name: this.name } } }
Now:
javascriptJSON.stringify(user)
produces:
json{"name":"Alice"}
The password is gone.
Why?
Because JSON.stringify() called:
javascriptuser.toJSON()
before serializing it.
Getters can do the same kind of thing.
javascriptconst invoice = { subtotal: 100, get total() { console.log("calculating...") return this.subtotal * 1.2 } }
When JSON serialization accesses total, the getter runs.
So serialization isn't always this passive operation I thought it was.
Serializing an object can execute application code.
Then There Was Object Identity
This one is subtle.
Suppose:
javascriptconst shared = { name: "Alice" } const original = { owner: shared, reviewer: shared }
Both properties point to the exact same object:
javascriptoriginal.owner === original.reviewer // true
Now do the usual JSON round trip:
javascriptconst copy = JSON.parse(JSON.stringify(original)) console.log(copy.owner === copy.reviewer) // false
The data looks the same, but the relationship is gone.
JSON stored:
json{ "owner": { "name": "Alice" }, "reviewer": { "name": "Alice" } }
It doesn't know that both originally pointed to the same object.
And if the object points back to itself:
javascriptconst obj = {} obj.self = obj
then:
javascriptJSON.stringify(obj)
just throws.
JSON can represent nested data.
It can't represent an arbitrary JavaScript object graph.
So What About Cloning?
This is where all of this finally connected for me.
I've seen this pattern everywhere:
javascriptconst copy = JSON.parse(JSON.stringify(original))
It's often described as a quick way to deep clone an object.
But after seeing all these examples, I wouldn't call it a general-purpose deep clone anymore.
Because look at everything we've already lost:
textlarge Number → precision can be lost undefined → disappears Date → string NaN → null Map / Set → type/state can disappear class instance → plain object shared reference → separate objects circular reference → error
That's not really "copying the object".
It's:
textJavaScript object ↓ JSON representation ↓ new JavaScript object
And those are not necessarily the same thing.
This Also Changed How I Think About APIs
This is probably the most useful part for me.
Instead of thinking:
textBackend object ↓ JSON.stringify() ↓ Frontend gets the same thing
I now think:
textBackend data ↓ Define what should cross the boundary ↓ JSON representation ↓ Parse ↓ Validate ↓ Convert into application types
For example, instead of casually sending:
javascript{ id: someBigInt, createdAt: someDate, roles: someSet }
I'd define the API shape explicitly:
json{ "id": "9007199254740993", "createdAt": "2026-07-21T12:00:00.000Z", "roles": ["admin", "developer"] }
Now everyone knows what they're dealing with.
The ID is a string.
The date is an ISO timestamp.
The roles are an array.
No guessing.
The Thing I Actually Learned
The interesting part of all this wasn't really JSON.
It was the idea of boundaries.
Whenever data moves from one system to another, something has to translate it.
And that translation can lose information.
JSON just makes this really easy to overlook because it looks so familiar.
So now when I see:
javascriptJSON.stringify(data)
I don't automatically think:
"I'm converting my object to a string."
I think:
"I'm converting my application's data into JSON's data model."
That small difference in thinking explains a lot of the weird behavior we see with JSON.
And honestly, that's probably the biggest takeaway I got from digging into this.