Web Workers Structured Clone: What Can't Cross the Boundary
Functions, DOM nodes, and class prototypes can't survive postMessage. Here's exactly what fails, what works, and the zero-copy escape hatches.
Offload CPU-heavy work to a Web Worker, and soon you'll hit the wall: postMessage()
can't send everything. Functions, DOM nodes, closures — all throw DataCloneError.
Understanding what survives the structured clone algorithm (and what doesn't) is essential
for architecting worker-based applications.
What the Structured Clone Algorithm Rejects
The structured clone algorithm serializes data for transfer between contexts. It handles most JavaScript values — but not these:
// ❌ Functions — always fail
worker.postMessage({ callback: () => console.log('hi') });
// DataCloneError: function () => console.log('hi') could not be cloned
// ❌ DOM nodes — not serializable
worker.postMessage({ el: document.body });
// DataCloneError: HTMLBodyElement could not be cloned
// ❌ Symbols — unique by design
worker.postMessage({ id: Symbol('unique') });
// DataCloneError: Symbol(unique) could not be cloned
// ❌ WeakMap / WeakSet — references can't survive
worker.postMessage({ cache: new WeakMap() });
// DataCloneError: WeakMap could not be cloned
// ❌ Property descriptors, getters, setters — lost
const obj = {};
Object.defineProperty(obj, 'computed', { get: () => 42 });
worker.postMessage(obj);
// Receives: {} — the getter is silently droppedWhat Survives (With Caveats)
Most built-in types clone correctly, but some lose identity or behavior:
// ✅ Plain objects, arrays, primitives — work fine
worker.postMessage({ data: [1, 2, 3], name: 'test' });
// ✅ Nested objects, circular references — handled
const circular = { a: 1 };
circular.self = circular;
worker.postMessage(circular); // Works!
// ✅ Date, RegExp, Map, Set — cloned correctly
worker.postMessage({
date: new Date(),
regex: /pattern/gi,
map: new Map([['key', 'value']]),
set: new Set([1, 2, 3])
});
// ⚠️ Error objects — message survives, stack may vary
worker.postMessage({ err: new Error('oops') });
// Receives Error with message, but stack trace handling differs by browser
// ⚠️ Class instances — become plain objects
class User { constructor(name) { this.name = name; } greet() { return 'hi'; } }
worker.postMessage({ user: new User('Alice') });
// Receives: { name: 'Alice' } — prototype chain lost, methods goneTransferable Objects: Zero-Copy Performance
For large binary data, cloning is expensive. Transferables move the underlying memory instead of copying — but the original becomes unusable:
// Create 8MB buffer
const buffer = new ArrayBuffer(8 * 1024 * 1024);
console.log(buffer.byteLength); // 8388608
// Transfer (not copy) to worker
worker.postMessage(buffer, [buffer]);
console.log(buffer.byteLength); // 0 — detached!
// ⚠️ TypedArrays aren't transferable — their .buffer is
const uint8 = new Uint8Array(1024);
worker.postMessage(uint8, [uint8]); // ❌ TypeError
worker.postMessage(uint8, [uint8.buffer]); // ✅ WorksFull List of Transferables
ArrayBuffer— raw binary dataMessagePort— bidirectional communication channelsReadableStream,WritableStream,TransformStreamImageBitmap— decoded image dataOffscreenCanvas— canvas rendering without DOMAudioData,VideoFrame— WebCodecs mediaRTCDataChannel— WebRTC data
Workarounds for Functions and Behavior
You can't send functions, but you can send instructions:
// ❌ Can't send the function
const processor = (x) => x * 2;
worker.postMessage({ fn: processor });
// ✅ Send operation name + data, execute in worker
worker.postMessage({ operation: 'multiply', args: [5, 2] });
// worker.js
self.onmessage = ({ data: { operation, args } }) => {
const operations = {
multiply: (a, b) => a * b,
transform: (arr) => arr.map(x => x * 2),
};
if (operations[operation]) {
self.postMessage({ result: operations[operation](...args) });
}
};Preserving Class Instances
// Define serialization protocol
class User {
constructor(name, id) {
this.name = name;
this.id = id;
}
toJSON() { return { __type: 'User', name: this.name, id: this.id }; }
static fromJSON(data) { return new User(data.name, data.id); }
}
// Send
worker.postMessage(JSON.stringify(new User('Alice', 1)));
// Receive and reconstruct
const reviver = (key, value) => {
if (value?.__type === 'User') return User.fromJSON(value);
return value;
};
const user = JSON.parse(data, reviver); // User instance restoredSharedArrayBuffer: True Shared Memory
Unlike transferables (which move data), SharedArrayBuffer lets both threads
access the same memory simultaneously — with all the race condition risks that implies:
// Main thread
const shared = new SharedArrayBuffer(1024);
const view = new Int32Array(shared);
worker.postMessage({ shared }); // No transfer list — it's shared
// Both threads can now read/write view[0]
// Use Atomics for thread-safe operations:
Atomics.add(view, 0, 1);
Atomics.load(view, 0);
Atomics.wait(view, 0, expectedValue); // Block until value changes
Caution: SharedArrayBuffer requires
cross-origin isolation headers
(Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy)
due to Spectre mitigations.
Key Takeaways
- Functions, DOM nodes, Symbols, and WeakMap/WeakSet can never cross the
postMessageboundary - Class instances lose their prototype — design serialization/deserialization protocols
- Use transferables (
ArrayBuffer, streams) for large binary data to avoid copy overhead - Transferred objects become detached — the original is unusable after transfer
SharedArrayBufferenables true shared memory but requires COOP/COEP headers and careful synchronization
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement