EdgeCases Logo
Feb 2026
Browser APIs
Surface
6 min read

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.

web-workers
structured-clone
postmessage
transferable
browser-apis
performance

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 dropped

What 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 gone

Transferable 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]); // ✅ Works

Full List of Transferables

  • ArrayBuffer — raw binary data
  • MessagePort — bidirectional communication channels
  • ReadableStream, WritableStream, TransformStream
  • ImageBitmap — decoded image data
  • OffscreenCanvas — canvas rendering without DOM
  • AudioData, VideoFrame — WebCodecs media
  • RTCDataChannel — 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 restored

SharedArrayBuffer: 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 postMessage boundary
  • 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
  • SharedArrayBuffer enables true shared memory but requires COOP/COEP headers and careful synchronization

Advertisement

Related Insights

Explore related edge cases and patterns

TypeScript
Surface
TypeScript's satisfies Operator: Validate Without Widening
6 min
CSS
Deep
CSS Scroll-Driven Animations: When animation-timeline Breaks Your Layout
7 min

Advertisement