Structured Error Serialization Across Threads

The structured clone algorithm can transfer Error objects in modern browsers, but it drops the stack property in Firefox and handles cause inconsistently across engines. For production telemetry you need a serializeError / deserializeError pair that extracts every useful property to a plain object before postMessage and reconstructs a genuine Error on the other side. This page belongs to Production Error Telemetry for Web Workers and is part of the Debugging, Profiling & Production Optimization reference.

Minimal Reproducible Example

// error-serialization.ts

export interface SerializedError {
  __type: 'SerializedError';
  name: string;
  message: string;
  stack: string;
  cause?: SerializedError | string;
  // DOMException extras
  domCode?: number;
}

/**
 * Serialize any thrown value to a plain object safe for postMessage.
 * Call this inside the worker before self.postMessage().
 */
export function serializeError(value: unknown, depth = 0): SerializedError {
  // Guard against infinite recursion in deeply nested causes
  if (depth > 5) {
    return { __type: 'SerializedError', name: 'SerializationDepthExceeded', message: String(value), stack: '' };
  }

  if (value instanceof DOMException) {
    return {
      __type: 'SerializedError',
      name: value.name,
      message: value.message,
      stack: value.stack ?? '',
      domCode: value.code,
      cause: value.cause !== undefined ? serializeError(value.cause, depth + 1) : undefined,
    };
  }

  if (value instanceof Error) {
    return {
      __type: 'SerializedError',
      name: value.name,
      message: value.message,
      stack: value.stack ?? '',
      cause: value.cause !== undefined ? serializeError(value.cause, depth + 1) : undefined,
    };
  }

  // Primitives thrown directly: throw 'oops'; throw 42;
  return {
    __type: 'SerializedError',
    name: 'NonErrorThrow',
    message: String(value),
    stack: '',
  };
}

/**
 * Reconstruct a real Error from a serialized envelope.
 * Call this on the main thread after receiving a WORKER_ERROR message.
 */
export function deserializeError(serialized: SerializedError): Error {
  let cause: Error | string | undefined;
  if (serialized.cause !== undefined) {
    if (typeof serialized.cause === 'string') {
      cause = serialized.cause;
    } else {
      cause = deserializeError(serialized.cause);
    }
  }

  // Reconstruct DOMException with its numeric code
  if (serialized.domCode !== undefined) {
    const dom = new DOMException(serialized.message, serialized.name);
    // DOMException.stack is read-only in some engines; override via Object.defineProperty
    try {
      Object.defineProperty(dom, 'stack', { value: serialized.stack, configurable: true });
    } catch {
      // silently ignore if not configurable
    }
    return dom;
  }

  // Map well-known error types back to their constructors
  const ctor = ERROR_CONSTRUCTORS[serialized.name] ?? Error;
  const err = cause !== undefined ? new ctor(serialized.message, { cause }) : new ctor(serialized.message);
  err.name = serialized.name;
  err.stack = serialized.stack;
  return err;
}

const ERROR_CONSTRUCTORS: Record<string, new (...args: unknown[]) => Error> = {
  Error,
  TypeError,
  RangeError,
  ReferenceError,
  SyntaxError,
  URIError,
  EvalError,
};

Line-by-Line Walkthrough

depth guard in serializeError. Error chains that reference themselves (or are very deeply nested via cause) would otherwise cause infinite recursion. The depth limit of 5 is arbitrary but covers all realistic cause chains. When the limit fires, the serialized object preserves the stringified value so at least something reaches the main thread.

instanceof DOMException branch. DOMException is thrown by many Web APIs available in workers — fetch, FileReader, IndexedDB, crypto, and OffscreenCanvas. Its code property (a numeric constant like DOMException.ABORT_ERR = 20) is useful for programmatic handling. Extracting it into domCode lets the main thread dispatch on it without needing a DOMException instance.

instanceof Error branch. value.stack ?? '' handles environments where stack is undefined on Error (it is non-standard but universally present in practice). The empty string fallback prevents downstream code from receiving undefined where a string is expected.

NonErrorThrow fallback. JavaScript allows throw 'a string' or throw 42. These are rare in typed codebases but common in old third-party libraries. Wrapping them in a NonErrorThrow envelope makes them visible in telemetry rather than silently disappearing.

deserializeError — reconstructing the constructor. Using new TypeError(msg) instead of new Error(msg) is important for Sentry and other tools that group errors by type. Sentry uses err.name for display but also inspects the constructor name in some grouping heuristics. Restoring the original constructor gets you accurate issue grouping.

err.stack = serialized.stack. Assigning to stack is writable on Error in V8, SpiderMonkey, and JavaScriptCore. This restores the original worker stack string so downstream source-map expansion works against the correct minified frames. Without this assignment the error would carry the main thread’s stack — which points to deserializeError itself, not the actual failure site.

Handling Custom Error Subclasses

Application code frequently defines custom error classes. The serialization pair handles them transparently as long as you register the constructor:

// custom-errors.ts
export class WorkerTimeoutError extends Error {
  readonly taskId: string;
  constructor(taskId: string) {
    super(`Worker task ${taskId} timed out`);
    this.name = 'WorkerTimeoutError';
    this.taskId = taskId;
  }
}

export class DataValidationError extends Error {
  readonly field: string;
  constructor(field: string, message: string) {
    super(message);
    this.name = 'DataValidationError';
    this.field = field;
  }
}
// error-serialization.ts  (extend the constructor map)
import { WorkerTimeoutError, DataValidationError } from './custom-errors';

const ERROR_CONSTRUCTORS: Record<string, new (...args: unknown[]) => Error> = {
  Error,
  TypeError,
  RangeError,
  ReferenceError,
  SyntaxError,
  URIError,
  EvalError,
  WorkerTimeoutError: WorkerTimeoutError as unknown as new (...args: unknown[]) => Error,
  DataValidationError: DataValidationError as unknown as new (...args: unknown[]) => Error,
};

Custom properties like taskId and field are not preserved by serializeError in its current form — it only captures the standard Error interface. For important custom properties, extend SerializedError with an optional extra record:

export interface SerializedError {
  __type: 'SerializedError';
  name: string;
  message: string;
  stack: string;
  cause?: SerializedError | string;
  domCode?: number;
  extra?: Record<string, unknown>; // custom properties
}

// In serializeError, after the Error branch:
if (value instanceof Error) {
  const extra: Record<string, unknown> = {};
  for (const key of Object.keys(value)) {
    if (!['name', 'message', 'stack', 'cause'].includes(key)) {
      extra[key] = (value as Record<string, unknown>)[key];
    }
  }
  return {
    __type: 'SerializedError',
    name: value.name,
    message: value.message,
    stack: value.stack ?? '',
    cause: value.cause !== undefined ? serializeError(value.cause, depth + 1) : undefined,
    extra: Object.keys(extra).length ? extra : undefined,
  };
}

Gotchas and Edge Cases

stack is not part of the ECMAScript spec. Every major engine provides it, but the format differs. V8 produces at FunctionName (file:line:col) multi-line strings; SpiderMonkey uses FunctionName@file:line:col. Source-map tools handle both formats, but any regex parsing you write must account for both.

DOMException.stack is read-only in older Safari. The try/catch around Object.defineProperty in deserializeError handles this. In Safari < 16, the stack property silently fails to set; the DOMException is still reconstructed correctly — you just lose the stack in that environment.

cause chains across different error types. An Error can have a cause that is itself a DOMException, which has a cause that is a plain string. The recursive serializeError call handles this correctly as long as the depth limit is not hit. The most common real-world case is two or three levels deep.

error.cause was standardized in ES2022. Older bundler targets (e.g. target: es2019) may need a polyfill or you may encounter cause: undefined on errors created in environments without native support. Check your tsconfig.json lib and target settings if you see missing cause chains.

Performance rule of thumb. Serializing a typical Error with a 20-frame stack trace takes under 0.02 ms in V8. Deserializing (including the Object.defineProperty call) is similarly fast. Neither is a bottleneck — errors are cold-path events and this serialization is negligible compared to the postMessage IPC itself (~0.05–0.2 ms round-trip for a small message).

Frequently Asked Questions

Why can't I just send an Error object directly over postMessage?
You can in Chrome and Edge 98+, but Firefox strips the stack property when cloning Error objects, and Safari’s handling of cause is partial. Extracting name, message, stack, and cause to a plain object before calling postMessage guarantees the same data in all browsers and removes the ambiguity entirely.
How do I handle DOMException in the serialization pair?
DOMException is structured-cloneable but you still want to capture its code and name properties for diagnostics. Extend serializeError with an instanceof DOMException branch that copies name, message, code, and stack to the plain-object envelope.

See also