Why JSON.stringify(error) prints {} in your logs, and how to actually log an Error properly
Try this in a Node REPL:
JSON.stringify(new Error('boom'))
// → '{}'An empty object. Not {"message":"boom"}, not even a hint that this was an error. If you’ve ever wired up console.log(JSON.stringify(err)) in an API route and then wondered why your logs show {} for every caught exception, this is why — and it’s a trap that’s easy to fall into because it looks like it should just work.
I ran into this directly while building @developerehsan/nextjs-logger’s error handling, and it’s worth understanding properly rather than just copy-pasting a fix.
Why this happens: enumerability
JSON.stringify only serializes an object’s own enumerable properties. Error.prototype defines message, name, and stack as accessors or plain properties, but when you create an error with new Error('boom'), the resulting instance’s own message property is set with enumerable: false. You can check this yourself:
const err = new Error('boom')
Object.getOwnPropertyDescriptor(err, 'message')
// → { value: 'boom', writable: true, enumerable: false, configurable: true }enumerable: false means for...in, Object.keys(), and — critically — JSON.stringify all skip it. This isn’t a bug; it’s intentional in the spec, going back to how errors were designed before anyone thought hard about JSON serialization being a common thing you’d want to do with them. But it means the “just JSON.stringify it” instinct, which works for almost every other kind of object in JavaScript, silently fails for the one kind of object you most need to log correctly.
The naive fixes, and where they fall short
Fix attempt 1: pull out message and stack manually.
function serializeError(err: Error) {
return { message: err.message, stack: err.stack }
}Better than nothing, but this drops:
name(so you can’t tell aTypeErrorfrom aRangeErrorfrom your own custom error class at a glance)cause— the standardError.prototype.causechain (ES2022), which is how you preserve “this failed because that failed” without swallowing the original errorAggregateError.errors— the array of underlying errors on anAggregateError, e.g. fromPromise.anyrejecting- any custom fields you attached, like
err.codeorerr.statusCode, which are common patterns for typed error handling and are themselves also non-enumerable-by-default if assigned in a constructor viaObject.defineProperty, though usually they’re plain assignments and are enumerable — the inconsistency itself is part of the problem
Fix attempt 2: recursively walk getOwnPropertyNames.
This is closer to correct — Object.getOwnPropertyNames(err) does return message and stack regardless of enumerability, since it ignores the enumerable flag entirely. But now you need to handle:
- Circular references (an error whose
causeis itself, or two errors that reference each other — rare, but a crash-in-the-logger from a circular JSON stringify is a genuinely bad failure mode, since the thing meant to help you debug a crash becomes the thing that crashes) - Depth limits (a
causechain can be arbitrarily long; you don’t want to serialize 40 levels deep by accident) - The fact that
AggregateError.errorsis itself an array ofErrors that each need the same treatment, recursively
What a proper serializer looks like
Here’s roughly the shape nextjs-logger’s error handling uses internally (simplified for this post — the actual implementation also handles data sanitization and redaction inline):
function serializeError(err: unknown, depth = 0, seen = new WeakSet<object>()): unknown {
if (depth > 10) return '[max depth exceeded]'
if (!(err instanceof Error)) {
// Not an Error at all — someone did `throw 'a string'` or
// `throw { code: 'BAD' }`. Handle it rather than dropping it.
return err
}
if (seen.has(err)) return '[circular]'
seen.add(err)
const result: Record<string, unknown> = {
name: err.name,
message: err.message,
stack: err.stack,
}
if (err.cause !== undefined) {
result.cause = serializeError(err.cause, depth + 1, seen)
}
if (err instanceof AggregateError) {
result.errors = err.errors.map((e) => serializeError(e, depth + 1, seen))
}
// Own extras: code, statusCode, digest (Next.js attaches this to
// errors it catches internally), or anything else a custom error
// class or the app attached.
for (const key of Object.getOwnPropertyNames(err)) {
if (['name', 'message', 'stack', 'cause', 'errors'].includes(key)) continue
result[key] = (err as Record<string, unknown>)[key]
}
return result
}A few things worth calling out about this shape:
It handles non-Error throws. TypeScript types a catch clause’s parameter as unknown specifically because JavaScript lets you throw anything — a string, a plain object, undefined. A logger that assumes err instanceof Error and calls err.message on a thrown string will itself throw, which is the worst possible failure mode for error-logging code. Every log method in nextjs-logger accepts unknown as its first argument for exactly this reason — no cast needed at the call site. One caveat on the simplified version above: err instanceof Error is a fine first check, but it returns false for an error that crossed a realm boundary — a Node vm context, a worker thread, or an error that was serialized and rebuilt on the other side of a network hop. If any of those apply to you, fall back to the internal class tag (Object.prototype.toString.call(err) === '[object Error]') and then to a duck-type check for a string message and stack, so a real error from another realm does not get dumped out as a plain object.
It walks cause recursively, not just one level. A common pattern is re-throwing with context: throw new Error('checkout failed', { cause: originalError }). If your serializer only reads the top error, you lose the actual root cause — the ECONNREFUSED at the bottom of the chain, say — and you’re left staring at “checkout failed” with no idea why.
It captures “own extras” generically. Custom error classes often attach fields like statusCode, code, or (in Next.js specifically) digest, which Next.js adds to errors it catches internally so you can correlate a client-visible error boundary with the exact server-side exception. A serializer that hardcodes { message, stack } misses all of these; walking getOwnPropertyNames catches whatever’s actually there without needing to know the error class ahead of time.
It guards against circular references and unbounded depth, so a pathological error graph degrades to a placeholder string instead of crashing the logger.
That 'errors' in the skip list is not cosmetic. I left it out of my first draft of this and it quietly broke the AggregateError case, which is worth spelling out because the bug is invisible until you look closely at the output. AggregateError.errors is an own property, so Object.getOwnPropertyNames returns it just like message and stack:
Object.getOwnPropertyNames(new AggregateError([new Error('x')], 'agg'))
// → [ 'stack', 'message', 'errors' ]Which means the extras loop runs after the AggregateError branch and assigns result.errors a second time — overwriting the nicely serialized array with the raw Error objects you just spent a recursive call converting. Those raw errors then hit JSON.stringify downstream and come out as [{}, {}]: the exact empty-object problem this whole post is about, sneaking back in through the fix for it. Skipping the key is the one-word repair, and it is the kind of thing you only notice if you assert on the shape rather than glance at the terminal.
What this looks like printed
With a serializer like this wired into a pretty-printer, a caught error renders as something you can actually read at a glance:
10:23:45.123 [ERROR] StripeCardError: Your card was declined
StripeCardError: Your card was declined
at chargeCard (app/lib/payments.ts:42:11)
at Checkout (app/checkout/page.tsx:18:5)
props: {"code":"card_declined","statusCode":402}
caused by:
Error: connect ECONNREFUSED 10.0.0.4:443
at TCPConnectWrap.afterConnect (node:net:1607:16)Compare that to {}, or to [object Object] if you tried template-string interpolation instead of JSON.stringify (which hits the same non-enumerability problem from a different angle — ${err} calls Error.prototype.toString, which gives you name: message and nothing else, no stack, no cause).
Writing a test for this instead of trusting it by eye
It’s worth pinning this behavior down with an actual test rather than eyeballing terminal output, because a regression here is easy to miss — the serializer still runs without throwing even if it silently starts dropping a field, so nothing obviously breaks. A few cases worth covering directly:
it('captures name, message, and stack for a plain Error', () => {
const err = new Error('boom')
const result = serializeError(err)
expect(result).toMatchObject({ name: 'Error', message: 'boom' })
expect((result as any).stack).toContain('boom')
})
it('walks the full cause chain, not just one level', () => {
const root = new Error('connection refused')
const middle = new Error('query failed', { cause: root })
const top = new Error('checkout failed', { cause: middle })
const result = serializeError(top) as any
expect(result.cause.message).toBe('query failed')
expect(result.cause.cause.message).toBe('connection refused')
})
it('handles a thrown non-Error value without throwing itself', () => {
expect(() => serializeError('a plain string')).not.toThrow()
expect(() => serializeError({ code: 'BAD_REQUEST' })).not.toThrow()
})
it('serializes AggregateError.errors instead of leaving raw Errors', () => {
const agg = new AggregateError([new Error('one'), new Error('two')], 'all failed')
const result = serializeError(agg) as any
expect(result.errors).toHaveLength(2)
expect(result.errors[0]).toMatchObject({ name: 'Error', message: 'one' })
// The real check: not still an Error instance.
expect(result.errors[0]).not.toBeInstanceOf(Error)
})
it('does not infinite-loop on a circular cause reference', () => {
const a: any = new Error('a')
const b: any = new Error('b', { cause: a })
a.cause = b // circular
expect(() => serializeError(a)).not.toThrow()
})That last case is the one I’d actually recommend prioritizing if you only add one test: a circular reference is rare in practice, but a serializer that hangs or throws on one turns a minor edge case into an outage in exactly the code path you’re relying on to tell you about outages.
One more trap: redaction has to run after serialization, not before
If your logging pipeline also redacts sensitive fields (stripping password, token, and similar keys before anything is written), the ordering matters and it’s easy to get backwards. Redaction needs to run on the serialized error object — after getOwnPropertyNames has pulled out custom fields like a token an error class happened to attach — not on the original Error instance, where those same fields are just as non-enumerable as message was in the first place and a naive key-matching redactor would walk right past them without ever seeing the field it was supposed to catch. If you’re composing a serializer and a redactor as separate pipeline steps, double-check the serializer runs first, and test it directly: attach a token field to a custom error, serialize it, then confirm redaction actually replaces it in the output rather than silently leaving a secret sitting in a field the redactor never looked at because it was still hidden behind the same enumerability quirk this whole post is about.
The takeaway
If you’re writing any kind of logging or error-reporting code and you’re currently doing JSON.stringify(err) or console.log('Error:', err) and trusting it to capture what you need — check what actually lands in your logs the next time a real error hits cause or comes from an AggregateError. Enumerability is one of those JavaScript quirks that’s invisible until the one time you actually need the data that’s silently missing, usually while debugging a production incident where the log line is the only thing you have.
An Error that serializes to {} is the polite version of this failure, because at least the rest of the log line survives. There are two values that take everything else down with them — a BigInt and an object containing itself — and I measured that separately in one BigInt in your log data deletes every other field.