One BigInt in your log data deletes every other field
You log four useful things:
log.info('order placed', {
route: '/checkout',
userId: 42,
total: 19.99,
durationNs: end - start,
})In your terminal it looks perfect. In production, the log line arrives like this:
{"level":"info","message":"order placed","data":"[unserializable data]"}Not one field is missing. All of them are. The route is gone, the user id is gone, the total is gone. You still have the words “order placed”, which is exactly the part you already knew.
One BigInt in your log data deletes every other field next to it, and nothing throws or warns you on the way. I measured what actually happens, and the cause is a small rule in JavaScript that almost nobody is told about.
The short version
JSON.stringify cannot handle two kinds of values. Not “handles them badly” —
it refuses, and throws an error:
JSON.stringify({ orderId: 10n }) // TypeError: Do not know how to serialize a BigInt
const req = { id: 1 }
req.self = req
JSON.stringify(req) // TypeError: Converting circular structure to JSONThose are real outputs from Node v22, not from memory. The BigInt rule is in
the language spec itself — JSON.stringify on MDN
states plainly that a BigInt value throws a TypeError, because JSON has no
way to write a number that big.
Now think about where that JSON.stringify call lives. It lives inside your
logger, because JSON is how logs get shipped anywhere. So when it throws, the
logger is the thing holding the broken pieces — and a logger that crashes your
checkout route because you asked it to print something would be a terrible
logger. So it catches the error.
And once it has caught the error, what can it do? It has no partial result.
JSON.stringify is all-or-nothing: it either gives you the whole string or it
gives you an exception. There is no “here are the three fields that worked.”
So the logger writes down the only honest thing it can: "[unserializable data]", and your other three fields go in the bin with the bad one.
flowchart TD
A["your object<br/>route, userId, total, durationNs"] --> B{"JSON.stringify"}
B -->|"every value is JSON-safe"| C["full log line<br/>all 4 fields"]
B -->|"one BigInt or one loop"| D["TypeError thrown"]
D --> E["logger catches it<br/>so your route keeps working"]
E --> F["no partial result exists<br/>data: '[unserializable data]'"]There is no third branch. That is the whole bug.
Watching it happen
I ran this against the published @developerehsan/nextjs-logger (v1.0.1), one
line per case, so you can see exactly which values survive:
const log = createLogger('shop')
const cyc = { id: 1 }; cyc.self = cyc
log.info('t', { orderId: 'A-1', userId: 42, total: 19.99 })
log.info('t', { orderId: 10n, userId: 42, total: 19.99, region: 'eu' })
log.info('t', { userId: 42, meta: { deep: { amount: 5n } } })
log.info('t', { userId: 42, req: cyc })
log.info('t', { userId: 42, tags: new Map([['a', 1]]) })
log.info('t', { userId: 42, note: undefined })
log.info('t', { userId: 42, ms: NaN })The output, trimmed to the data field:
{"orderId":"A-1","userId":42,"total":19.99}
"[unserializable data]"
"[unserializable data]"
"[unserializable data]"
{"userId":42,"tags":{}}
{"userId":42}
{"userId":42,"ms":null}Read that list carefully, because it splits into two very different failure modes.
Lines 2, 3 and 4 lost everything. One BigInt, even buried two levels deep
inside meta.deep.amount, and the whole object is replaced. Depth does not
save you. The bad value does not have to be near the top — it just has to be
somewhere.
Lines 5, 6 and 7 lost one field each and kept the rest. A Map quietly
became {}. An undefined value made its key vanish completely. NaN turned
into null. Those are annoying, and worth knowing, but they are local damage.
The BigInt and the circular reference are different in kind: they are not a bad
field, they are a bad object.
That is the sentence to remember. Some values spoil themselves. Two values spoil everything around them.
Why this only hurts you in production
Here is the cruel part, and the reason this bug survives code review:
const data = { route: '/checkout', userId: 42, durationNs: end - start }
console.log(data)
// { route: '/checkout', userId: 42, durationNs: 922n } ← perfect
JSON.stringify(data)
// TypeError: Do not know how to serialize a BigInt ← brokenconsole.log does not use JSON. It uses Node’s inspector, which is happy to
print a BigInt — it even adds the little n so you can tell it apart from a
normal number. So on your laptop, staring at a pretty terminal, the field looks
great: two back-to-back hrtime calls measured 922 ns apart on my machine, and
console.log printed that number without complaint while JSON.stringify
refused the exact same object.
The moment those same logs have to become JSON to travel somewhere — your log service, a file, an HTTP request — the same object stops working. Dev and prod disagree, and only prod is telling the truth.
Where BigInts come from when you never typed 10n
Almost nobody writes 10n by hand. BigInts arrive through the back door:
Timing code. process.hrtime.bigint()
returns a BigInt, because nanoseconds overflow a normal JavaScript number. So
the most natural way to time a request produces a BigInt:
const start = process.hrtime.bigint()
await handleCheckout()
const end = process.hrtime.bigint()
log.info('checkout done', { userId, durationNs: end - start }) // whole line lostYou added timing to debug a slow route. The timing deleted the log you were debugging with.
Databases. Big integer columns — bigint, int8, anything counting past
about nine quadrillion — are mapped to BigInt by several drivers and ORMs,
because a normal JavaScript number cannot hold them without losing precision.
So a row you pulled from the database and logged “just to see it” can carry one.
Circular references have their own back door: request objects, socket objects, and any tree where a child holds a reference back to its parent. You never wrote the loop, the framework did.
The fix, and the two bugs I hit writing it
The idea is simple: clean the object before handing it to the logger. Turn BigInts into strings, cut loops, leave everything else alone.
My first attempt was the version you will find on most Stack Overflow answers —
a WeakSet of every object seen so far:
function safe(value) {
const seen = new WeakSet()
return JSON.parse(JSON.stringify(value, (_key, v) => {
if (typeof v === 'bigint') return v.toString()
if (typeof v === 'object' && v !== null) {
if (seen.has(v)) return '[circular]'
seen.add(v)
}
return v
}))
}It fixes the crash. It also introduces a quieter bug, which I only caught because I tested a case that had nothing to do with loops:
const user = { id: 7 }
safe({ author: user, reviewer: user })
// { author: { id: 7 }, reviewer: "[circular]" }Nothing here is circular. The same object simply appears twice, which is completely normal — the same user as author and reviewer, the same config object passed to two fields. But “have I seen this object anywhere before” is the wrong question. The right question is “am I currently inside this object?” A loop means an object contains itself. Two siblings pointing at one object is just sharing.
So the fix is to track ancestors — the path you are currently standing in — and drop them again on the way back out:
function safe(value, ancestors = []) {
if (typeof value === 'bigint') return value.toString()
if (typeof value !== 'object' || value === null) return value
if (value instanceof Date || value instanceof Error) return value
if (ancestors.includes(value)) return '[circular]'
const path = [...ancestors, value]
if (Array.isArray(value)) return value.map((v) => safe(v, path))
const out = {}
for (const [k, v] of Object.entries(value)) out[k] = safe(v, path)
return out
}That third line — the Date and Error one — is bug number two, and I only
added it after watching my own fix make things worse.
A Date is an object, so my loop walked into it with Object.entries. A Date
has no enumerable own properties, so it came out as {}. Same for Error.
I had taken two values the logger already handled perfectly well and flattened
them into empty braces:
before my fix: {"at":"1970-01-01T00:00:00.000Z"}
after my fix: {"at":{}}Dates and Errors already know how to turn themselves into something readable —
Date has a toJSON method, and a decent logger has a dedicated error
serializer that keeps name, message, stack and the cause chain. (If you
have ever seen an error log as {}, that missing serializer is exactly why —
I pulled that one apart in
why error objects print as empty in JSON logs.)
The correct move is to not touch them and let the layer below do its job.
Proof it works
The same cases as before, with safe() in front of them:
log.info('t', safe({ route: '/checkout', userId: 42, durationNs: end - start }))
log.info('t', safe({ author: user, reviewer: user }))
log.info('t', safe({ route: '/c', req: cyc }))
log.info('t', safe({ userId: 42, at: new Date(0), amount: 5n, deep: { arr: [1n, 'x'] } })){"route":"/checkout","userId":42,"durationNs":"1062"}
{"author":{"id":7},"reviewer":{"id":7}}
{"route":"/c","req":{"id":1,"self":"[circular]"}}
{"userId":42,"at":"1970-01-01T00:00:00.000Z","amount":"5","deep":{"arr":["1","x"]}}Every field survives. The loop is cut at exactly one place instead of taking the object down with it. The Date is still a real timestamp.
And here is the check I keep next to it, so a future edit cannot quietly bring back either bug:
import assert from 'node:assert'
const user = { id: 7 }
const cyc = { id: 1 }; cyc.self = cyc
assert.deepStrictEqual(safe({ a: user, b: user }), { a: { id: 7 }, b: { id: 7 } })
assert.strictEqual(safe(cyc).self, '[circular]')
assert.strictEqual(safe({ n: 9n }).n, '9')
assert.deepStrictEqual(safe({ arr: [1n, 2] }).arr, ['1', 2])
assert.ok(safe({ at: new Date(0) }).at instanceof Date)
assert.ok(safe({ e: new Error('x') }).e instanceof Error)Six lines. The first two are the two bugs above, frozen so they stay fixed.
Why strings, and not numbers
safe() turns a BigInt into a string, not a number, and that is deliberate.
The whole reason a value is a BigInt is that it is too big for a normal
JavaScript number to hold exactly. Number(9007199254740993n) gives you
9007199254740992 — the last digit is simply wrong. If that BigInt is an order
id, you have just logged a different order.
A string is the boring, correct answer. "9007199254740993" is still the same
id, still searchable in your log tool, still copy-pasteable into a database
query. You lose the ability to do maths on it inside your log viewer, which you
were not going to do anyway.
The one place to be careful: if a field is sometimes a number and sometimes a
string, some log tools will get confused about the field’s type. Pick one shape
per field and stay with it — if durationNs is a string, keep it a string
everywhere, or store milliseconds as a real number in a separate field if you
want to chart it.
The rule worth keeping
When a log line comes back as one useless placeholder instead of your data,
you are not looking at a logger bug. You are looking at JSON.stringify
refusing an object, and the logger being polite about it.
Two values cause it: BigInt, and an object that contains itself. Both can be
hiding anywhere inside the thing you logged, and neither shows up in
console.log, which is why this always gets discovered in production.
Clean the value before you log it, track ancestors rather than everything seen,
and leave Date and Error alone — they are already better at this than your
helper is.
If you want the log line to keep the same shape every time as well as the same fields, the next step is validating that shape at the boundary, which I covered in how to stop your structured logs from silently drifting out of shape.