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.
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.
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.
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.
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.
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.
serializeError.