How to Log Custom Error Properties Without Blooming Payloads

When handling unhandled promise rejections in modern JS, attaching rich context to Error instances is tempting — but without careful serialization boundaries it produces payloads that breach telemetry ingestion limits, a failure mode that sits within the broader discipline of Core JavaScript Error Handling & Boundaries.

Custom Error serialization pipeline: bloomed vs bounded paths A flow diagram showing an Error instance entering a serialization stage (toJSON / Object.defineProperty). From there two paths diverge: the bloomed path (red) bypasses the size gate and delivers a 4 MB+ payload that is rejected with HTTP 413 at the ingestion endpoint; the bounded path (green) passes the size gate (under 64 KB), is accepted by the telemetry SDK, and successfully reaches the ingestion endpoint. Error Instance + custom props Serialization toJSON() Object.defineProperty Size Gate < 64 KB? Bloomed path (4 MB+) HTTP 413 Rejected by endpoint Bounded path (< 64 KB) Telemetry SDK Sentry / DataDog / OTel Non-enumerable context + toJSON whitelist + size gate = bounded, accepted payload

Symptom / Trigger

The most common surface is a 413 Request Entity Too Large from the telemetry SDK’s ingestion endpoint. You may also see a TypeError when the error object contains a circular reference, or — the subtler trap — a silent empty object when you forget that native Error properties are non-enumerable.

// --- Symptom 1: HTTP 413 from telemetry endpoint ---
// Console output after SDK flush:
// POST https://ingest.example-telemetry.io/v1/errors 413 (Request Entity Too Large)
// Error: Request failed with status code 413
//   at TelemetryClient.flush (telemetry-sdk.min.js:1)

// --- Symptom 2: TypeError from circular reference ---
const err = new Error('Payment failed');
err.self = err; // circular reference
JSON.stringify(err);
// TypeError: Converting circular structure to JSON
//   --> starting at object with constructor 'Error'
//   |     property 'self' -> object with constructor 'Error'
//   --- repeating ---

// --- Symptom 3: silent empty object (the sneakiest) ---
const err2 = new Error('timeout');
console.log(JSON.stringify(err2)); // {}
// message, stack, and name are all non-enumerable — JSON.stringify sees nothing

All three symptoms share the same root: JSON.stringify does not handle Error objects the way developers expect.

Root Cause Explanation

Native Error properties are deliberately non-enumerable. The ECMAScript specification defines message, stack, and name with enumerable: false, so JSON.stringify — which only traverses own enumerable properties — returns {} for a bare new Error('msg').

Direct property assignment flips that default. When you write err.userId = '123', the property lands on the instance with enumerable: true. From that point on, JSON.stringify traverses whatever you attached — including large nested objects, binary buffers, and DOM references.

// The broken pattern — three distinct failure modes in one block
class ApiError extends Error {
  constructor(message, context) {
    super(message);
    this.name = 'ApiError';

    // BAD: enumerable by default — JSON.stringify traverses all of this
    this.context = context;           // may be a 4 MB response body
    this.sessionState = window.__session; // DOM-attached state
    this.request = context.req;       // circular: req.res.req.res…
  }
}

const err = new ApiError('Checkout failed', largeContext);

// All three failure modes:
JSON.stringify(err);                  // may be 4 MB+ → HTTP 413
// OR throws TypeError if any ref is circular
// AND without context: JSON.stringify(new Error('x')) === '{}'

The combination of non-enumerable native properties and enumerable custom properties creates asymmetric serialization: you lose the diagnostic fields you need (message, stack) and keep the heavy fields you should discard.

Step-by-Step Fix

Step 1 — Define a BoundedError subclass with non-enumerable context and a toJSON whitelist

Object.defineProperty lets you attach context to the error instance while keeping it invisible to JSON.stringify’s default traversal. The toJSON method is the hook JSON.stringify calls when it encounters an object — override it to emit only the scalar fields you actually need.

class BoundedError extends Error {
  constructor(message, context = {}) {
    super(message);
    this.name = 'BoundedError';

    // Non-enumerable: JSON.stringify ignores this property entirely
    Object.defineProperty(this, '_context', {
      value: context,
      enumerable: false,   // hidden from JSON.stringify default traversal
      writable: true,      // allows test mocks to replace the value
      configurable: true   // allows subclasses to redefine the descriptor
    });
  }

  toJSON() {
    // Explicit whitelist — only scalar fields cross the wire
    return {
      name: this.name,
      message: this.message,
      stack: this.stack,
      // Extract only the primitive identifiers you need for correlation
      context: {
        userId: this._context?.userId ?? null,
        requestId: this._context?.requestId ?? null,
        endpoint: this._context?.endpoint ?? null,
        statusCode: this._context?.statusCode ?? null
      }
    };
  }
}

// Usage
const err = new BoundedError('Checkout failed', {
  userId: 'u_8821',
  requestId: 'req_abc123',
  endpoint: '/api/checkout',
  statusCode: 502,
  // The following are held in _context but never emitted:
  responseBody: massiveResponseBlob,  // 3 MB — stays in memory, never serialized
  sessionState: window.__session       // DOM-attached — stays in memory, never serialized
});

console.log(JSON.stringify(err));
// {"name":"BoundedError","message":"Checkout failed","stack":"BoundedError: ...","context":{...scalar fields only}}

Step 2 — Implement a safeSerializeError size gate

Even a well-designed toJSON can emit unexpectedly large stacks in monorepos with deep call chains. Add a byte-length gate that catches anything above your SDK’s hard limit before it leaves the process.

/**
 * Serialize an Error to JSON, truncating if the payload exceeds maxBytes.
 * Falls back to a minimal { name, message, stack } envelope on oversize.
 *
 * @param {Error} err
 * @param {number} [maxBytes=65536]  64 KB default; most SDKs enforce 128 KB max
 * @returns {string} JSON string safe for telemetry transmission
 */
function safeSerializeError(err, maxBytes = 65536) {
  let payload;
  try {
    payload = JSON.stringify(err); // calls toJSON() if defined
  } catch (serializeErr) {
    // Catches circular reference TypeErrors from objects without toJSON
    payload = JSON.stringify({
      name: err.name ?? 'Error',
      message: err.message ?? '(no message)',
      stack: err.stack ?? '',
      serializeError: serializeErr.message  // record that serialization itself failed
    });
  }

  // Byte-length measurement — cross-environment
  const byteLength =
    typeof Buffer !== 'undefined'
      ? Buffer.byteLength(payload, 'utf8')          // Node.js
      : new TextEncoder().encode(payload).length;   // browser

  if (byteLength > maxBytes) {
    // Payload exceeds limit — emit only the non-enumerable diagnostic core
    return JSON.stringify({
      name: err.name ?? 'Error',
      message: err.message ?? '(no message)',
      stack: err.stack ?? '',
      truncated: true,          // flag tells the ingestion pipeline context was dropped
      originalByteLength: byteLength
    });
  }

  return payload;
}

Step 3 — Add a replacer function to strip known bloat keys

When you cannot control every error class in a codebase (third-party libraries, legacy code), a replacer function passed to JSON.stringify acts as a surgical filter. It intercepts each key-value pair during traversal and can replace, truncate, or drop values before they reach the output.

// Keys whose values should be stripped or replaced during serialization
const BLOAT_KEYS = new Set([
  'responseBody',
  'requestBody',
  'sessionState',
  'domNode',
  'buffer',
  'arrayBuffer',
  'data',      // common axios field — may be a full response payload
]);

/**
 * JSON.stringify replacer that strips known-large fields and
 * truncates oversized strings in place.
 */
function errorReplacer(key, value) {
  if (BLOAT_KEYS.has(key)) {
    return '[stripped]'; // preserve the key in output for debugging context
  }
  // Truncate strings beyond 512 chars (catches long SQL queries, HTML snippets, etc.)
  if (typeof value === 'string' && value.length > 512) {
    return value.slice(0, 512) + '…[truncated]';
  }
  // Drop DOM nodes and binary objects
  if (
    (typeof window !== 'undefined' && value instanceof Node) ||
    value instanceof ArrayBuffer ||
    ArrayBuffer.isView(value)
  ) {
    return '[binary/DOM stripped]';
  }
  return value; // pass everything else through unchanged
}

// Usage alongside safeSerializeError
function safeSerializeWithReplacer(err, maxBytes = 65536) {
  let payload;
  try {
    payload = JSON.stringify(err, errorReplacer); // replacer runs before toJSON result is emitted
  } catch (e) {
    payload = JSON.stringify({ name: err.name, message: err.message, stack: err.stack });
  }

  const byteLength =
    typeof Buffer !== 'undefined'
      ? Buffer.byteLength(payload, 'utf8')
      : new TextEncoder().encode(payload).length;

  return byteLength <= maxBytes
    ? payload
    : JSON.stringify({ name: err.name, message: err.message, stack: err.stack, truncated: true });
}

Step 4 — Wire the bounded serializer into the global rejection handler

All of the above machinery is only effective if it sits in the path that every unhandled rejection traverses. Hook both the browser unhandledrejection event and the Node.js unhandledRejection process event, and route every captured rejection through safeSerializeError before handing off to the SDK. For the underlying mechanics of intercepting rejections globally, see best practices for try/catch in async loops for how async boundaries affect capture scope.

// browser — unhandledrejection event
window.addEventListener('unhandledrejection', (event) => {
  event.preventDefault(); // suppress browser console noise

  const reason = event.reason;
  const err = reason instanceof Error ? reason : new Error(String(reason));

  // Guarantee bounded serialization regardless of how the error was constructed
  const serialized = safeSerializeError(err);

  telemetry.captureRaw(serialized); // send the pre-serialized string, bypass SDK re-stringify
});

// Node.js — unhandledRejection process event
process.on('unhandledRejection', (reason, promise) => {
  const err = reason instanceof Error ? reason : new Error(String(reason));
  const serialized = safeSerializeError(err);

  telemetry.captureRaw(serialized);

  // Optional: re-throw to preserve Node.js default exit behavior
  // process.exitCode = 1;
});

Passing the pre-serialized JSON string directly to telemetry.captureRaw (or the equivalent raw-event method in your SDK) prevents the SDK from running its own JSON.stringify pass on the object a second time, which would bypass your toJSON override and potentially re-expand the payload.

Verification

The following test creates a BoundedError with a deliberately massive context object and asserts that the serialized output stays under 1 KB while preserving the diagnostic fields that matter.

// Node.js test (no framework required — works with node --test or copy-paste in REPL)
import assert from 'node:assert/strict';
import { test } from 'node:test';

test('BoundedError serializes within 1 KB and preserves diagnostic fields', () => {
  // Simulate a 4 MB context object — the kind that causes HTTP 413 without gating
  const massiveContext = {
    userId: 'u_8821',
    requestId: 'req_abc123',
    endpoint: '/api/checkout',
    statusCode: 502,
    responseBody: 'x'.repeat(4 * 1024 * 1024) // 4 MB string
  };

  const err = new BoundedError('Checkout failed', massiveContext);
  const json = JSON.stringify(err);

  // Payload must be under 1 KB
  const byteLength =
    typeof Buffer !== 'undefined'
      ? Buffer.byteLength(json, 'utf8')
      : new TextEncoder().encode(json).length;

  assert.ok(byteLength < 1024, `Payload is ${byteLength} bytes — expected < 1024`);

  // Diagnostic fields must be preserved
  const parsed = JSON.parse(json);
  assert.equal(parsed.name, 'BoundedError',    'name preserved');
  assert.equal(parsed.message, 'Checkout failed', 'message preserved');
  assert.ok(parsed.stack.includes('BoundedError'), 'stack preserved');

  // Scalar context fields must be whitelisted
  assert.equal(parsed.context.userId, 'u_8821',      'userId whitelisted');
  assert.equal(parsed.context.statusCode, 502,        'statusCode whitelisted');

  // The massive responseBody must NOT appear in the output
  assert.equal(parsed.context.responseBody, undefined, 'responseBody stripped');

  console.log('OK — payload size:', byteLength, 'bytes');
});

Expected output:

OK — payload size: 243 bytes

Edge Cases & Gotchas

  • Error.prototype.stack format differs between V8 and SpiderMonkey. V8 (Chrome, Node.js) prefixes stack frames with at FunctionName (file:line:col); Firefox uses FunctionName@file:line:col. Always test .stack extraction logic in both engines, and reference source map generation and stack trace debugging when you need to symbolicate minified frames from either engine.

  • toJSON is only called when the Error is the top-level serialized value or a direct property. If a BoundedError instance lives inside an array or nested object — for example { errors: [err1, err2] }JSON.stringify walks to each element and calls toJSON on each. But errors nested inside arrays inside arrays may not get the same treatment depending on the depth and structure. The replacer function approach from Step 3 is more robust for deeply nested error collections, and it is especially important when working with the global listeners described in mastering window.onerror and global event listeners, which may aggregate multiple errors into a single payload.

  • Object.defineProperty with configurable: false blocks subclasses and test mocks. If you define _context as configurable: false, Jest’s jest.replaceProperty and Vitest’s vi.spyOn cannot reassign it, and any subclass that tries to redefine the descriptor throws a TypeError: Cannot redefine property: _context. Always set configurable: true in shared library code.

  • Buffer is not available in browser environments. The Buffer.byteLength(str, 'utf8') call in Step 2 will throw ReferenceError: Buffer is not defined in a browser. The new TextEncoder().encode(payload).length branch is the universal fallback. If you bundle with webpack or Rollup and inject a Buffer polyfill, profile whether the polyfill’s byte-length implementation is accurate for your target character sets — some polyfills undercount multi-byte UTF-8 sequences.

FAQ

Why does JSON.stringify(new Error('msg')) return {}? ECMAScript defines message, stack, and name on Error.prototype or the instance with enumerable: false. JSON.stringify enumerates only own properties whose enumerable descriptor is true. Without a toJSON override, there are no enumerable own properties to emit, so the result is an empty object. This is the spec behavior — not a bug in JSON.stringify.

Can I just wrap JSON.stringify in a try/catch to handle circular references instead of using Object.defineProperty? A try/catch handles the TypeError from circular references but does nothing to bound payload size. A 4 MB payload that serializes without error still reaches the SDK and triggers an HTTP 413. You need both layers: non-enumerable storage (or a toJSON whitelist) to prevent large objects from entering the JSON graph, and a byte-length gate to catch anything that slips through. The try/catch in safeSerializeError is a safety net for unforeseen edge cases, not a primary defense.

Does this approach work with third-party Error subclasses that I cannot modify? Yes — the replacer function from Step 3 operates independently of the error class. Pass it as the second argument to JSON.stringify and it will filter keys regardless of whether the object has a toJSON method. For errors that do have toJSON, the replacer runs on the object that toJSON returns, so both mechanisms compose correctly. The one constraint is that a toJSON method that returns a primitive (string or number) will bypass the replacer entirely — in practice no well-formed error serializer does this.