Structured Error Serialization Across Threads

A serializeError / deserializeError pair that turns any thrown value into a plain, provably cloneable envelope inside the worker and rebuilds a genuine Error — right constructor, right stack, right cause chain — on the main thread.

This is the type-preserving half of the pipeline assembled in Production Error Telemetry for Web Workers, which sits inside the Debugging, Profiling & Production Optimization reference. That parent guide establishes where the serialization step belongs — between the worker’s global handlers and the postMessage that carries the report out. This page builds the function itself, including the cases the minimal version drops: DOMException codes, AggregateError members, deep cause chains, and application-defined subclasses. Once errors arrive intact you can forward them to a backend as shown in Capturing Worker Stack Traces in Sentry.

Why the Structured Clone Algorithm Is Not Enough

Error objects have been structured-cloneable since Chrome 98, so the naive version — self.postMessage({ type: 'WORKER_ERROR', error }) — appears to work on the machine you develop on. It fails in three distinct ways in production, and only the first is widely known.

Lossy. stack is an engine extension, not part of the spec’s serialization steps for Error. Firefox therefore clones the error without it. The report reaches your backend with a type and a message and zero frames, which tells you that something broke and never where. Safari’s handling of cause is partial for the same reason: the deeper the chain, the less survives.

Fatal. This is the failure mode that actually loses reports. Structured clone throws DataCloneError on any non-cloneable value in the object graph — functions, DOM nodes, class instances with live handles. A custom error carrying { retry: () => …, socket } makes postMessage throw inside the error handler, so the original failure is replaced by a second failure that nobody catches. The error report destroys itself on the way out.

Ambiguous. Even where cloning succeeds, what arrives is engine-dependent, so the same crash produces different fingerprints on different browsers and your telemetry backend groups it as several distinct issues.

Flattening to a plain object of strings and numbers removes all three problems at once: it is total, it is cloneable by construction, and it is byte-identical across engines. The general behaviour of the algorithm you are opting out of is covered in the Step-by-Step Guide to the Structured Clone Algorithm.

Lossy, fatal and ambiguous: the three failure modes of posting a live Error One custom UploadError, carrying name, message, stack, a RangeError cause, a statusCode of 507, a retry callback and a socket handle, is posted with postMessage. Three failure modes follow. Lossy: Firefox clones it without the stack, so the report arrives with name, message and cause but stack undefined — you learn what broke, never where. Fatal: the retry callback is not cloneable, so postMessage throws DataCloneError inside the catch block and the report destroys itself on the way out. Ambiguous: Chrome delivers four fields and six frames while Safari delivers only a partial cause chain, so one bug becomes two fingerprints in the backend. Below, posting the output of serializeError instead sends a plain envelope with a __type tag, name, message, stack, a nested cause and an extra record holding statusCode 507 — total, cloneable by construction, identical in every engine, with the retry callback and socket dropped by the clone probe. postMessage(err) — one custom error, three ways to lose the report catch (err) — class UploadError extends Error name · message · stack · cause: RangeError('index 12') statusCode: 507 · retry: () => … · socket: WebSocket LOSSY Firefox — stack is an engine extension FATAL any engine — one uncloneable property AMBIGUOUS Chrome vs Safari — same crash { name: 'UploadError', message: 'upload failed', stack: undefined, cause: {…} } postMessage({ error: err }) DataCloneError retry: () => … could not be cloned Chrome 124: 4 fields, 6 frames Safari 17: cause chain partial two fingerprints, one bug no frames: you learn what broke, never where in the worker it broke it throws inside the catch — the report destroys itself on exit one bug, several issues in triage — grouping keys on engine-specific fields postMessage(serializeError(err)) — flatten before the boundary { __type: 'SerializedError', name, message, stack, cause: {…}, extra: { statusCode: 507 } } total · cloneable by construction · identical in every engine — retry and socket dropped by the clone probe
The same UploadError, four outcomes. Only the bottom path is total, cloneable by construction and byte-identical across engines — and it is the only one that cannot throw inside the handler that was supposed to report the failure.

Minimal Reproducible Example

One module, imported by both threads, so the envelope type cannot drift between the producer and the consumer.

// error-serialization.ts  — imported by the worker AND the main thread

export interface SerializedError {
  __type: 'SerializedError';
  name: string;
  message: string;
  stack: string;
  cause?: SerializedError;
  domCode?: number;              // DOMException.code
  errors?: SerializedError[];    // AggregateError.errors
  extra?: Record<string, unknown>;
}

const MAX_CAUSE_DEPTH = 5;
const STANDARD_KEYS = new Set(['name', 'message', 'stack', 'cause', 'errors']);

/** Flatten any thrown value into a plain object safe for postMessage. */
export function serializeError(value: unknown, depth = 0): SerializedError {
  if (depth > MAX_CAUSE_DEPTH) {
    return envelope('SerializationDepthExceeded', safeString(value), '');
  }

  // DOMException FIRST: it now inherits from Error in Web IDL, so the
  // Error branch below would match it and silently drop `code`.
  if (typeof DOMException !== 'undefined' && value instanceof DOMException) {
    return {
      ...envelope(value.name, value.message, value.stack ?? ''),
      domCode: value.code,
      cause: serializeCause(value, depth),
    };
  }

  if (value instanceof AggregateError) {
    return {
      ...envelope(value.name, value.message, value.stack ?? ''),
      errors: value.errors.map((e) => serializeError(e, depth + 1)),
      cause: serializeCause(value, depth),
      extra: harvestExtras(value),
    };
  }

  if (value instanceof Error) {
    return {
      ...envelope(value.name, value.message, value.stack ?? ''),
      cause: serializeCause(value, depth),
      extra: harvestExtras(value),
    };
  }

  // `throw 'oops'` and `throw 42` are legal and still happen in old libraries.
  return envelope('NonErrorThrow', safeString(value), '');
}

function envelope(name: string, message: string, stack: string): SerializedError {
  return { __type: 'SerializedError', name, message, stack };
}

function serializeCause(err: Error, depth: number): SerializedError | undefined {
  return err.cause === undefined ? undefined : serializeError(err.cause, depth + 1);
}

/** Own enumerable extras (taskId, statusCode, …), minus anything uncloneable. */
function harvestExtras(err: Error): Record<string, unknown> | undefined {
  const extra: Record<string, unknown> = {};
  for (const key of Object.keys(err)) {
    if (STANDARD_KEYS.has(key)) continue;
    let value: unknown;
    try {
      value = (err as unknown as Record<string, unknown>)[key]; // getters can throw
    } catch {
      continue;
    }
    if (isCloneable(value)) extra[key] = value;
  }
  return Object.keys(extra).length > 0 ? extra : undefined;
}

function isCloneable(value: unknown): boolean {
  if (value === null) return true;
  const t = typeof value;
  if (t === 'string' || t === 'number' || t === 'boolean' || t === 'undefined') return true;
  try {
    structuredClone(value); // cheap for the small values that belong on an error
    return true;
  } catch {
    return false;
  }
}

function safeString(value: unknown): string {
  try {
    return typeof value === 'string' ? value : JSON.stringify(value) ?? String(value);
  } catch {
    return Object.prototype.toString.call(value);
  }
}

The rebuild side is deliberately in the same module, so a change to the envelope forces both halves to be updated together:

// error-serialization.ts (continued)

type ErrorCtor = new (message?: string, options?: { cause?: unknown }) => Error;

export const ERROR_CONSTRUCTORS = new Map<string, ErrorCtor>([
  ['Error', Error], ['TypeError', TypeError], ['RangeError', RangeError],
  ['ReferenceError', ReferenceError], ['SyntaxError', SyntaxError],
  ['URIError', URIError], ['EvalError', EvalError],
]);

/** Rebuild a real Error from an envelope. Call on the receiving thread. */
export function deserializeError(input: SerializedError): Error {
  const cause = input.cause ? deserializeError(input.cause) : undefined;

  if (input.domCode !== undefined) {
    // `name` is the second argument; the legacy-name table restores `code`.
    const dom = new DOMException(input.message, input.name);
    defineStack(dom, input.stack);
    return dom;
  }

  if (input.errors) {
    const agg = new AggregateError(
      input.errors.map(deserializeError),
      input.message,
      cause ? { cause } : undefined,
    );
    return finish(agg, input);
  }

  const Ctor = ERROR_CONSTRUCTORS.get(input.name) ?? Error;
  const err = new Ctor(input.message, cause ? { cause } : undefined);
  return finish(err, input);
}

function finish(err: Error, input: SerializedError): Error {
  err.name = input.name;               // survives an unregistered subclass name
  defineStack(err, input.stack);
  if (input.extra) Object.assign(err, input.extra);
  return err;
}

function defineStack(err: Error, stack: string): void {
  // `stack` is read-only on DOMException in some engines; never let this throw.
  try {
    Object.defineProperty(err, 'stack', { value: stack, writable: true, configurable: true });
  } catch {
    /* stack unavailable in this engine — the rest of the report is intact */
  }
}

Line-by-Line Walkthrough

The __type discriminator. The receiving thread demultiplexes several message shapes. A literal tag lets a type guard — msg.error?.__type === 'SerializedError' — narrow the union without duck-typing on name and message, which any plain object might carry. It also makes a malformed payload from an older worker bundle fail loudly instead of deserializing into a blank Error.

Branch order is load-bearing. DOMException is tested first because Web IDL now specifies it as inheriting from Error. In engines that implement that, an instanceof Error branch placed first would match and drop code; in engines that have not, instanceof Error is false and the value would fall through to NonErrorThrow, losing the name too. Testing the most specific type first is correct under both behaviours. AggregateError sits second for the same reason — it is an Error subclass, and only its own branch captures the errors array.

The depth guard. err.cause = err is a real pattern in retry wrappers that re-wrap the same error object. Recursion without a bound overflows the stack inside the error handler, which is the worst place to fault. Truncating at five levels replaces the tail with a SerializationDepthExceeded envelope, so the outer frames — the ones you actually triage on — still arrive.

harvestExtras and the clone probe. Object.keys returns own enumerable properties only. In V8 message is own but non-enumerable and stack is an own accessor, so neither normally appears; the STANDARD_KEYS filter is defence against engines and transpiled subclasses that define them enumerably. The isCloneable probe is what prevents the fatal case: a retry callback or a socket handle on a custom error is skipped rather than allowed to make postMessage throw. Primitives short-circuit before the probe, so the common case costs one typeof.

Property reads are wrapped in try. Errors from ORMs and HTTP clients frequently expose lazy getters that touch a connection. Reading one during serialization can throw, and a throw here loses the original error entirely.

new DOMException(message, name). The name is the second argument, not the first — reversing them is the most common bug in hand-written versions of this function. Passing a legacy name such as AbortError makes the constructor restore code to 20 automatically; a modern name yields code === 0, which is exactly why the envelope carries domCode separately for dispatch.

Reassigning name after construction. WorkerTimeoutError will not be in the registry unless you put it there, so the fallback constructs a plain Error. Restoring name afterwards means the class is still identified correctly in the UI and in fingerprinting even when the constructor was not recoverable — degradation instead of loss.

defineStack rather than err.stack = …. Plain assignment works on V8, SpiderMonkey and JavaScriptCore for Error, but is a silent no-op on DOMException in older Safari where the property is read-only. defineProperty inside a try handles both. Restoring the worker’s stack matters because source-map expansion runs against these frames — skip it and every report resolves to deserializeError on the main thread rather than the real failure site.

One error, end to end: throw in the worker, real Error on the main thread A two-lane sequence. In the worker lane, top to bottom: a TypeError with a RangeError cause is thrown inside the task; self.onerror and self.onunhandledrejection catch it, the last point where the value is still a real Error; serializeError branches DOMException, then AggregateError, then Error, then NonErrorThrow, recursing into cause with a depth cap of five; harvestExtras runs the isCloneable probe, keeping taskId and dropping the retry callback before it can throw; self.postMessage sends one plain object. A dashed line marks the boundary, crossed by a structured clone of that plain object — no Error instance crosses it. In the main-thread lane: worker.onmessage type-guards on __type equal to SerializedError; deserializeError rebuilds through the DOMException constructor for a domCode envelope or the ERROR_CONSTRUCTORS registry with a fallback to Error; finish reassigns name so the class still fingerprints and defineStack restores the worker's frames; captureException finally receives a real TypeError with the worker frames and the cause chain intact. Worker thread Main thread throw inside the worker task TypeError: rows is undefined · cause: RangeError self.onerror + self.onunhandledrejection the last point where the value is still an Error serializeError(value) DOMException → AggregateError → Error → NonErrorThrow cause recursed, capped at MAX_CAUSE_DEPTH = 5 harvestExtras() + isCloneable() probe taskId: 'row-8821' → extra retry: () => … → dropped before it can throw self.postMessage({ type: 'WORKER_ERROR', error }) one plain object — nothing left that can fail worker.onmessage guard: msg.error?.__type === 'SerializedError' deserializeError(envelope) domCode → new DOMException(message, name) else ERROR_CONSTRUCTORS.get(name) ?? Error finish() — name, extra, stack name reassigned so the class still fingerprints defineStack restores the worker's frames captureException(error) a real TypeError · worker frames · cause chain postMessage — a structured clone of a plain object; no Error instance ever crosses this line
The only place the thrown value is still a real Error is inside the worker's global handler; the only place it is one again is after deserializeError. Everything between those two points is a plain object, which is precisely why the crossing cannot fail.

Preserving Custom Error Subclasses

Application errors carry the fields that make a report actionable — which task timed out, which field failed validation. Those fields survive as extra automatically; the class only survives if you register it.

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

export class DataValidationError extends Error {
  constructor(readonly field: string, message: string, options?: { cause?: unknown }) {
    super(message, options);
    this.name = 'DataValidationError';
  }
}
// error-registry.ts — imported for its side effect by both entry points
import { ERROR_CONSTRUCTORS } from './error-serialization';
import { WorkerTimeoutError, DataValidationError } from './custom-errors';

for (const Ctor of [WorkerTimeoutError, DataValidationError]) {
  // Subclass signatures differ from ErrorCtor; deserialize passes (message, options)
  // and the remaining fields are restored from `extra` by Object.assign.
  ERROR_CONSTRUCTORS.set(Ctor.name, Ctor as unknown as new (
    message?: string, options?: { cause?: unknown },
  ) => Error);
}

Two constraints follow from bundling. First, set this.name explicitly in every subclass: minifiers rename classes, so Ctor.name becomes t in the production bundle and registry lookups miss. Register under the literal string if you want to be certain. Second, a constructor whose required arguments are not (message, options) will be called with the wrong shape — WorkerTimeoutError above would receive the message as taskId. Either give every registered subclass a message-first signature, or restore those fields from extra, which finish already does after construction.

The constructor registry: one name string, three outcomes Three serialized envelopes on the left are looked up by name in ERROR_CONSTRUCTORS, a Map from string to error constructor, and rebuilt on the right. The envelope named WorkerTimeoutError, carrying taskId and elapsedMs in extra, matches a registered application subclass and is rebuilt as a real WorkerTimeoutError with taskId restored from extra. The envelope named TypeError matches a built-in entry and is rebuilt as a TypeError with stack and cause reattached. The envelope named CacheMissError was never registered, so the lookup misses and falls back to a plain Error whose name property is reassigned to CacheMissError — degraded, not lost. The registry panel lists Error, TypeError and RangeError plus four more built-ins, then WorkerTimeoutError and DataValidationError as registered, then CacheMissError as absent; a miss produces a fallback, never a throw, because the lookup is a plain string match. A note below warns that minifiers rename classes, so Ctor.name becomes t in the production bundle and the lookup misses; register under the literal string and give every registered subclass a message-and-options signature. deserializeError — one name string, three outcomes serialized envelope rebuilt on the main thread ERROR_CONSTRUCTORS Map<string, ErrorCtor> 'WorkerTimeoutError' extra: { taskId, elapsedMs } cause: undefined 'TypeError' stack: 6 worker frames cause: RangeError 'CacheMissError' app class, never registered extra: { key: 'tile-9' } 'Error' built-in 'TypeError' built-in 'RangeError' … +4 more built-in 'WorkerTimeoutError' registered 'DataValidationError' registered 'CacheMissError' absent a miss returns the fallback, never a throw the lookup is a plain string match new WorkerTimeoutError(…) class preserved taskId restored from extra new TypeError(message) built-in, always in the registry stack + cause reattached new Error(message) err.name = 'CacheMissError' degraded, not lost Minifiers rename classes, so Ctor.name becomes 't' in the production bundle and every registry lookup misses. Register under the literal string, and give every registered subclass a (message, options) signature.
Registration is what preserves the class; extra preserves the fields either way. An unregistered name still arrives correctly labelled because finish reassigns name after construction — the failure mode is degradation, not loss.

Verifying the Round Trip

The boundary is simulated exactly by structuredClone, so the whole contract is testable in a plain unit test with no worker involved.

import { describe, it, expect } from 'vitest';
import { serializeError, deserializeError } from './error-serialization';

describe('error serialization round trip', () => {
  it('preserves type, stack and a two-level cause chain', () => {
    const root = new RangeError('index 12 out of bounds');
    const wrapped = new TypeError('row decode failed', { cause: root });

    // structuredClone applies the same rules postMessage would.
    const revived = deserializeError(structuredClone(serializeError(wrapped)));

    expect(revived).toBeInstanceOf(TypeError);
    expect(revived.stack).toBe(wrapped.stack);
    expect((revived.cause as Error).name).toBe('RangeError');
  });

  it('drops uncloneable properties instead of throwing', () => {
    const err = Object.assign(new Error('upload failed'), {
      statusCode: 507,
      retry: () => undefined,
    });

    const envelope = serializeError(err);
    expect(() => structuredClone(envelope)).not.toThrow();
    expect(envelope.extra).toEqual({ statusCode: 507 });
  });
});

The second test is the one worth having in CI. It fails the moment someone attaches a handle to an error class, which is the change that would otherwise silently start dropping reports in production. Pair it with the worker-side handler coverage in Fixing Uncaught Exceptions in Dedicated Workers.

Both round-trip tests, stage by stage through the clone gate Two five-stage pipelines, one per test. The first test starts with a new TypeError whose cause is a RangeError and whose stack has six frames, passes through serializeError producing a flat envelope with a nested cause, then through structuredClone which applies the same rules postMessage would, then through deserializeError which does a registry lookup and calls defineStack, and ends at the assertions: instanceof TypeError, stack equality and cause.name. The second test starts with an Error carrying statusCode 507 and a retry callback, passes through harvestExtras where 507 is kept and retry is dropped, then through structuredClone which does not throw and raises no DataCloneError, producing an extra record of just statusCode 507, and ends at the assertions not.toThrow and extra toEqual. Between the two rows a note explains that the boundary is simulated exactly by structuredClone so the whole contract is a plain unit test with no worker and no flake, and that the second test is the one worth having in continuous integration because it fails the moment someone attaches a live handle to an error class. it('preserves type, stack and a two-level cause chain') new TypeError cause: RangeError stack: 6 frames serializeError() flat envelope, cause nested structuredClone() same rules as postMessage deserializeError() registry lookup, defineStack expect(…) instanceof TypeError stack · cause.name The boundary is simulated exactly by structuredClone — the whole contract is a plain unit test: no worker, no flake. The second test is the one worth having in CI: it fails the moment someone attaches a live handle to an error class. it('drops uncloneable properties instead of throwing') Error + extras statusCode: 507 retry: () => … harvestExtras() 507 → kept retry → dropped structuredClone() does not throw no DataCloneError envelope.extra { statusCode: 507 } and nothing else expect(…) not.toThrow() extra toEqual(…) Green on both rows means: right constructor, worker frames intact, cause chain intact, nothing silently dropped.
The clone call in the middle of each row is the boundary. Everything to its left runs in the worker in production and in the test process here — which is why a defect that would only appear as a missing production report shows up as a red test instead.

Gotchas and Edge Cases

Stack formats differ by engine. V8 emits Error: msg\n at fn (file:line:col); SpiderMonkey emits fn@file:line:col with no header line. Source-map libraries handle both, but any regex you write to extract a top frame must, and a rule keyed on V8’s leading at silently matches nothing on Firefox traffic.

One throw, two stack string formats The same failure serialized in two engines. The V8 format used by Chrome, Edge and Node begins with a header line reading TypeError colon rows is undefined, followed by indented frames each prefixed with the word at, such as at decodeRow in worker.js line 41 column 17 and at onmessage in worker.js line 12 column 5. The SpiderMonkey format used by Firefox has no header line at all and writes each frame as function name, at sign, file, line and column — decodeRow at worker.js line 41 column 17 — with no at prefix. A note below warns that a rule keyed on V8's leading at prefix matches every Chrome frame and nothing on Firefox traffic, and that source-map libraries handle both formats while hand-written top-frame regular expressions usually do not. The same throw, two stack string formats V8 — Chrome, Edge, Node SpiderMonkey — Firefox TypeError: rows is undefined at decodeRow (worker.js:41:17) at onmessage (worker.js:12:5) header line, then an 'at ' prefix on every frame (no header line at all) [email protected]:41:17 [email protected]:12:5 function@file:line:col — no prefix, no header A top-frame rule keyed on V8's leading 'at ' matches every Chrome frame and nothing at all on Firefox traffic. Source-map libraries handle both formats; hand-written regexes usually handle one.
Both strings describe the same two frames. The serializer carries either one across unchanged — it is the code downstream that parses stack which has to accept both.

cause is ES2022. With a tsconfig.json target below es2022, TypeScript rejects the two-argument Error constructor and downlevelled subclasses may not forward the option at all, so chains arrive with cause: undefined and no error is raised anywhere. Check lib and target before assuming the serializer is at fault.

A postMessage inside a catch can itself throw. The clone probe removes the common cause, but a serializer that faults still swallows the original failure. Wrap the send in its own try, and in the fallback post { __type: 'SerializedError', name: 'SerializationFailure', message: String(err) } — a degraded report beats silence.

Envelope versions drift across deploys. A cached worker bundle from the previous release keeps posting the old shape to a freshly loaded page. Because both sides import the same module you will not catch this in development. Treat every field except name and message as optional on read, and consider adding a v integer to the envelope so the receiver can log a mismatch instead of dropping fields.

Errors are not the payload path. These envelopes are small plain objects and always cloned; the transfer-list optimisation described in Transferable Objects & Zero-Copy does not apply and would neuter buffers the worker still needs.

Performance Note

Serialization cost is irrelevant here and it is worth knowing by how much, so that nobody optimises it. Measured on Chrome 124 / V8 12.4 on a 2023-class laptop, serializeError on a TypeError with a 20-frame stack and one nested cause runs in 8–20 µs, dominated by the structuredClone probe on any non-primitive extras; deserializeError on the same envelope runs in 5–12 µs. The resulting envelope is 1.5–2.5 KB of JSON, and structured-cloning an object that size across the boundary costs well under 0.1 ms — against a postMessage round trip of roughly 0.05–0.2 ms for a small message.

Measure it in your own bundle rather than trusting the numbers:

const err = new TypeError('row decode failed', { cause: new RangeError('index 12') });

const t0 = performance.now();
for (let i = 0; i < 10_000; i++) structuredClone(serializeError(err));
const perOpMs = (performance.now() - t0) / 10_000;
console.log(`${(perOpMs * 1000).toFixed(1)} µs per serialize+clone`);

The rule of thumb: at up to ten worker errors per second — far beyond any healthy application — the pair costs under 0.5 ms of a second of thread time, three orders of magnitude below the data payloads these workers carry. Errors are a cold path. Spend the budget on completeness: an extra 400 bytes of cause chain and custom fields is the difference between a triaged bug and a reopened one. The only case that needs a bound is a runaway loop throwing thousands of identical errors, and the fix there is the rate-limiting and fingerprinting stage of the production error telemetry pipeline, not a faster serializer.

The whole error path against one frame A horizontal chart on a logarithmic microsecond scale running from one microsecond to one hundred milliseconds, measured on Chrome 124 and V8 12.4 on a 2023-class laptop. serializeError on a TypeError with a twenty-frame stack and one nested cause spans eight to twenty microseconds. deserializeError on the same envelope spans five to twelve microseconds. Structured-cloning the resulting envelope of one and a half to two and a half kilobytes is an upper bound of under one hundred microseconds. A postMessage round trip for a small message spans fifty to two hundred microseconds. A dashed reference line marks the 16.7 millisecond frame budget, roughly three orders of magnitude to the right of the serializer bars. A note below states that at ten worker errors per second — far beyond any healthy application — the pair costs under half a millisecond out of a full second of thread time. Where the serializer sits against everything else on the error path log scale · Chrome 124 / V8 12.4 16.7 ms frame budget serializeError() 20-frame stack, one cause deserializeError() registry + defineStack clone the envelope 1.5–2.5 KB of JSON postMessage round trip small message, for reference 8–20 µs 5–12 µs under 100 µs (upper bound) 0.05–0.2 ms 1 µs 10 µs 100 µs 1 ms 10 ms 100 ms At ten worker errors per second — far beyond any healthy application — the pair costs under 0.5 ms of a full second of thread time. Errors are a cold path: spend the budget on completeness, not on a faster serializer.
Three orders of magnitude separate the serializer from a single frame. The only case that needs a bound is a runaway loop, and the fix for that is fingerprinting and rate-limiting upstream — not a cheaper serializeError.

Frequently Asked Questions

Why not just post the Error object itself and let structured clone handle it?
Because the guarantee is weaker than it looks. Error objects have been cloneable since Chrome 98, but stack is an engine extension rather than a spec’d serialization step, so Firefox omits it — the crash arrives with a type and a message and no frames. Non-cloneable own properties are worse than lossy: a custom error carrying a callback or a DOM node makes postMessage throw DataCloneError, so the error report destroys itself on the way out. Flattening to a plain envelope first makes the payload provably cloneable and identical in every engine.
Do I have to branch on DOMException separately if it already inherits from Error?
Yes, and the branch has to come first. Web IDL now makes DOMException inherit from Error, so in engines that implement that, a plain instanceof Error branch matches it and silently drops the code property. In engines that have not caught up, instanceof Error is false and the value falls through to the non-error fallback, losing the name too. Testing instanceof DOMException before instanceof Error is correct under both behaviours, which is why the ordering in serializeError is load-bearing rather than stylistic.

See also