skip to content

A class instance with methods and getters is passed through structuredClone() or postMessage(). What arrives on the other side, and how should the value be designed so the receiver can work with it?

level: seniorimportance: should knowfreq 40%

answer

  1. data crosses, behaviour stays home
  2. the receiver has no shared prototype
  3. methods live where the clone never looks
  4. one loud failure, one silent one
  5. explicit payload, explicit rehydrate

basics

~20 s

Only own enumerable data arrives, as a plain object: the prototype is gone, so methods and instanceof fail, and getters have been flattened into fixed values. Design the boundary around explicit plain-data payloads plus a rehydrate function on the receiving side.

solid answer

~50 s

The clone algorithm moves data, never behaviour. The receiver gets an ordinary object with `Object.prototype` as its prototype, carrying only the instance's own enumerable string-keyed properties, so `clone instanceof User` is `false` and every prototype method is missing. Getters were invoked once during serialization and stored as plain data, so derived values are frozen snapshots rather than live computations — and any function stored as an own property, such as a callback assigned in the constructor, does not merely vanish: it throws `DataCloneError` and kills the whole clone. The right design is to stop sending domain objects. Define an explicit serializable shape — a `toJSONLike()` or `toPayload()` method producing plain data, and a static `from(payload)` on the receiving side that rebuilds an instance if behaviour is needed there. That makes the contract visible in code instead of implicit in whatever the object happens to hold.

code

javascript · 12 lines
javascript
class User {
  constructor(id, name) { this.id = id; this.name = name; }
  greet() { return `hi ${this.name}`; }
  toPayload() { return { v: 1, id: this.id, name: this.name }; }
  static from(p) { return new User(p.id, p.name); }
}

const clone = structuredClone(new User(1, 'ada'));
console.log(clone instanceof User, typeof clone.greet); // false "undefined"

const payload = structuredClone(new User(1, 'ada').toPayload());
console.log(User.from(payload).greet()); // "hi ada"

go deeper

for a junior

Remember that a cloned class instance is just data: methods are gone and instanceof is false. If the other side needs behaviour, something has to rebuild the object there.

for a middle

Explain the mechanics — own enumerable string-keyed properties only, prototype dropped, getters flattened — and note that an own function property throws DataCloneError rather than being skipped.

for a senior

Argue the design: an explicit payload type with a serialize method and a rehydrate factory, chosen because the receiver shares no heap and no code. Reject prototype reattachment and JSON round-tripping as workarounds, and say why.

for a principal

Set the boundary contract across the app: which values are transport payloads, who owns their schema, and how persisted payloads are versioned and migrated so a record written by an old release stays readable.

## What actually crosses The structured clone algorithm serializes an ordinary object by taking its own enumerable string-keyed properties and cloning their values. It does not record the constructor, does not record the prototype, and does not record property descriptors. Deserialization then builds a fresh plain object. For a class instance that means: - **Methods are gone.** Class methods live on `User.prototype`, and the prototype is not part of the serialization. Nothing about the instance's own data mentions them. - **`instanceof` fails.** The clone's prototype is `Object.prototype`. - **Getters become snapshots.** A `get fullName()` declared in the class body lives on the prototype and is never even visited. A getter defined as an own enumerable accessor *is* visited: it is called once and its return value stored as a data property. Either way the receiver has no live accessor. - **Private fields (`#count`) do not travel.** They are not properties at all, so there is nothing to enumerate. - **Own function properties throw.** `this.onChange = () => {}` in the constructor is an own enumerable property whose value is a function, so the clone fails with `DataCloneError` — a loud failure rather than a quiet loss. ```js class User { constructor(id, name) { this.id = id; this.name = name; } get initials() { return this.name[0].toUpperCase(); } greet() { return `hi ${this.name}`; } } const c = structuredClone(new User(1, 'ada')); c instanceof User; // false typeof c.greet; // "undefined" c.initials; // undefined (a prototype getter is never enumerated) c.id; // 1 ``` ## Why the platform refuses to do more The receiving side is not guaranteed to share anything with the sender. A worker has its own global scope and only whatever modules it imported; another document may be from a different origin; an IndexedDB record may be read back by a future version of the app whose `User` class has changed shape. Reconstructing a prototype would require the receiver to already have the identical class, and the algorithm has no way to know or check that. So it draws a firm line: data crosses, code does not. ## The design that follows Treat every clone boundary as a wire, and give it a payload type of its own. **1. Define the payload explicitly.** A plain object with primitives and cloneable built-ins. Nothing on it should be a function or a live view. **2. Serialize deliberately.** Give the domain class a method that produces the payload. This forces you to decide what belongs on the wire instead of discovering it via `DataCloneError` in production. **3. Rehydrate on the far side — only if behaviour is needed there.** A static factory, `User.from(payload)`, restores an instance. Often the receiver does not need behaviour at all: a worker computing a sum over records is happier with plain data. **4. Version the payload if it is persisted.** A message lives for milliseconds, but a record stored via a clone boundary lives until the user clears the site. Include a version field so the reading code can migrate old shapes. ```js class User { static from(p) { return new User(p.id, p.name); } toPayload() { return { v: 1, id: this.id, name: this.name }; } } worker.postMessage(user.toPayload()); ``` ## Anti-patterns to name - **`JSON.parse(JSON.stringify(instance))` as a workaround.** It avoids `DataCloneError` only because JSON *silently drops* functions — you have traded a loud failure for a quiet one, and lost your `Date`s and `Map`s as well. - **Rehydrating by copying the prototype back** with `Object.setPrototypeOf(clone, User.prototype)`. It appears to work, but the object never ran the constructor, so invariants, private fields and derived state may be missing. It also couples the receiver to the exact class the sender used. - **Sending the whole store.** "Just post the state object" works until someone attaches a callback or a DOM reference to it, at which point an unrelated feature breaks the message channel. ## What to say in an interview Lead with the rule — the algorithm copies data, not behaviour or identity — then show you know the two failure shapes it produces (silent prototype loss, loud `DataCloneError` on own function properties), and finish on the design: an explicit payload type with serialize and rehydrate functions, versioned when the value is persisted rather than merely posted.

  • Why not just call Object.setPrototypeOf(clone, User.prototype) on the receiving side?
    It restores method lookup but skips the constructor, so any invariant, private field or derived state established there is missing — you get an object that answers `instanceof` while being subtly incomplete. It also couples the receiver to the sender's exact class. A static `from(payload)` factory rebuilds through the real constructor.
  • A teammate says JSON.parse(JSON.stringify(instance)) avoids the DataCloneError, so it is safer. What's wrong with that?
    It does not fix anything — it hides the failure. JSON silently drops the function properties that made the clone throw, so the bug reappears later as an undefined callback, and you additionally lose Dates, Maps, Sets and cycles. A loud error at the boundary is the better outcome.
  • Does a getter declared in a class body appear on the clone at all?
    No. A class-body getter lives on the prototype, and only own properties are enumerated, so it is never visited and the clone has no such property. An own enumerable accessor defined directly on the instance is different: it is invoked once and its result stored as static data.
  • How should the payload differ when it is persisted rather than just posted to a worker?
    Add an explicit version field and treat the shape as long-lived data. A posted message is consumed immediately by code you just shipped; a stored record can be read back months later by a different app version, so the reader must be able to recognise and migrate an older shape rather than assuming today's fields.

saying these in an interview costs you the question

  • Expects methods to survive because the object cloned successfully
  • Suggests JSON round-tripping to dodge the DataCloneError
  • Reattaches the prototype and calls the object fully restored
  • Thinks private class fields are copied as properties
  • Assumes the receiving thread can see the sender's class definitions

context