In Node.js, it’s common practice to log objects during error handling or debugging using JSON.stringify
. It’s one of the first things you’re taught when learning Node.js.
Nowadays, I believe you should not use it when debugging, as there are a couple of footguns that are not obvious. Since I encountered this problem while troubleshooting a bug, I wanted to highlight what can happen behind the scenes.
Serialization Throws Link to heading
Circular References Link to heading
The most common error is probably the famous Converting circular structure to JSON
error. It can be easily triggered if you try to log request or response objects.
const obj = {}
obj.self = obj
JSON.stringify(obj) // ❌ TypeError: Converting circular structure to JSON
toJSON Can Throw Link to heading
toJSON
is a standardized method in JavaScript that allows objects to define how they should be serialized into JSON.
It works like this:
const user = {
name: "Alice",
password: "foobar123",
toJSON() {
return { name: this.name }
}
}
JSON.stringify(user) // '{"name":"Alice"}'
Depending on the implementation, this can potentially throw:
const bad = {
toJSON() {
throw new Error("oops!")
}
}
JSON.stringify(bad) // ❌ Uncaught Error: oops!
Getters That Throw Link to heading
Even if toJSON
is not defined, JSON.stringify
will iterate over all enumerable own properties. If any are getters that throw, it fails:
const obj = {
get fail() {
throw new Error('Boom')
}
}
JSON.stringify(obj) // ❌ Uncaught Error: Boom
This is a lesser-known trap. Non-enumerable getters are ignored:
const obj = {}
Object.defineProperty(obj, 'hidden', {
get() {
throw new Error('Ignored')
},
enumerable: false
})
JSON.stringify(obj) // '{}'
How JSON.stringify Works Link to heading
The internal sequence for serialization is as follows:
- Check for
toJSON
– If defined, call it and use the result. Exceptions bubble up. - Property traversal – Iterate over enumerable own properties. Accessor properties are invoked here. They are invoked, and if any throw, an error is thrown.
- Structural validation – Throw a
TypeError
if the resulting object has circular references.
Handling This the Right Way Link to heading
Node.js comes with the util
module, providing many useful utilities for us.
One of these is the inspect
function.
util.inspect
is designed to convert any JavaScript value into a string representation that’s readable and useful for debugging, logging and diagnostics. It is available since v0.3.0 and is also used in console.log
!
It avoids all the pitfalls described above. It bypasses toJSON
, ignores getters by default and handles circular structures by displaying them instead of throwing. Additionally, you can configure it to inspect deeply nested objects or beautify the output with colors.
const { inspect } = require("node:util")
const circularObj = {}
circularObj.self = circularObj
const toJsonObj = {
toJSON() {
throw new Error("oops!")
}
}
const getterObj = {
get fail() {
throw new Error('Boom')
}
}
inspect(circularObj) // '<ref *1> { self: [Circular *1] }'
inspect(toJsonObj) // '{ toJSON: [Function: toJSON] }'
inspect(getterObj) // '{ fail: [Getter] }'
Conclusion Link to heading
JSON.stringify
seems like a simple and harmless tool, but as we’ve seen, it can easily backfire in non-obvious ways. Whether it’s circular references, toJSON
methods, or getters that throw. Relying on it blindly during debugging or error handling can lead to confusing crashes or unexpected behavior.
For everyday logging and inspection, util.inspect
is the safer and more reliable choice. It gives you control, avoids the pitfalls, and plays nicely with even the most complex objects.
Next time you’re troubleshooting something weird, and things break the moment you try to log a response or error — take a closer look. It might just be JSON.stringify
in the end 😉