1014 words
5 minutes

structuredClone DataCloneError: Separate Data from Functions

2026-10-15
Frontend
JavaScript
/
Node.js
/
Troubleshooting

structuredClone({ save() {} }) throws DataCloneError: the object contains a function. Moving that callback into a nested object does not fix the error. Build a payload containing the data you intend to copy, and keep executable behavior outside that payload.

There is a second boundary to check. A class instance with only data fields can clone successfully while losing its prototype methods. A successful call therefore does not prove that the result can replace the original instance.

Separate a rejected value from lost behavior#

MDN’s structured clone guide documents that functions cannot be cloned and that prototype chains are not duplicated. Those rules produce different outcomes for an own method and a prototype method. The following cases were executed with Node.js 24.13.0 on macOS during article preparation on October 15, 2026.

Input in the fixtureObserved resultWhat to change
Object literal with save() {}DataCloneErrorExclude executable behavior from the payload
Nested options.onDone callbackDataCloneErrorProject the nested data fields too
Property whose value is a symbolDataCloneErrorChoose an explicit cloneable identifier representation
Job instance with data fields and prototype run()Clone succeeds; run is undefined and instanceof Job is falseReconstruct behavior explicitly if the consumer needs an instance
Same instance with an own onDone callback addedDataCloneErrorCopy selected data rather than the entire instance
Data graph with a cycle, a shared child, undefined, and a DateClone succeeds; assertions preserve those relationships and valuesDo not diagnose cycles alone as the cause

The table describes these inputs, rather than every possible object or platform type. In particular, the symbol case uses a symbol value; it is not a claim about symbol-keyed properties.

Reproduce the failure and the data-only fix#

Save this as clone-boundary.mjs and run node clone-boundary.mjs. It needs no package installation. The assertions verify the error name instead of matching engine-specific message text.

import assert from 'node:assert/strict';
for (const [label, value] of [
['own method', { save() {} }],
['nested callback', { options: { onDone() {} } }],
['symbol value', { token: Symbol('id') }],
]) {
assert.throws(() => structuredClone(value), { name: 'DataCloneError' });
console.log(`${label}: DataCloneError`);
}
class Job {
constructor(id) {
this.id = id;
this.options = { retries: 2 };
}
run() { return this.id; }
}
const job = new Job('job-7');
const plain = structuredClone(job);
assert.equal(plain instanceof Job, false);
assert.equal(typeof plain.run, 'undefined');
assert.notEqual(plain.options, job.options);
console.log(`prototype method: ${typeof plain.run}; instanceof Job: ${plain instanceof Job}`);
job.onDone = () => 'finished';
assert.throws(() => structuredClone(job), { name: 'DataCloneError' });
const payload = { id: job.id, options: { retries: job.options.retries } };
const copy = structuredClone(payload);
copy.options.retries = 5;
assert.equal(job.options.retries, 2);
assert.equal(copy.options.retries, 5);
assert.equal(Object.hasOwn(copy, 'onDone'), false);
console.log(`projected payload: source retries ${job.options.retries}; copy retries ${copy.options.retries}`);
const shallow = { ...job };
assert.equal(shallow.onDone, job.onDone);
assert.equal(shallow.options, job.options);
console.log('spread: callback and nested object still shared');
const shared = { count: 1 };
const graph = { left: shared, right: shared, missing: undefined,
created: new Date('2026-10-15T00:00:00Z') };
graph.self = graph;
const cloned = structuredClone(graph);
assert.equal(cloned.self, cloned);
assert.equal(cloned.left, cloned.right);
assert.notEqual(cloned.left, shared);
assert.equal(Object.hasOwn(cloned, 'missing'), true);
assert.equal(cloned.missing, undefined);
assert.equal(cloned.created instanceof Date, true);
assert.equal(cloned.created.getTime(), graph.created.getTime());
console.log('data graph: cycle, shared reference, undefined, and Date preserved');
console.log('all assertions passed');

Expected output:

own method: DataCloneError
nested callback: DataCloneError
symbol value: DataCloneError
prototype method: undefined; instanceof Job: false
projected payload: source retries 2; copy retries 5
spread: callback and nested object still shared
data graph: cycle, shared reference, undefined, and Date preserved
all assertions passed

The payload expression is an explicit field selection: it copies the job ID and retry count, leaving onDone on the original job. The test changes the cloned retry count and verifies that the source remains unchanged. This establishes the desired data independence for this payload; it does not validate arbitrary input or implement a general serializer.

cloned.left === cloned.right is also intentional. The clone has a new child object, but two references to the same source child remain two references to one cloned child. Deep copying does not require breaking every relationship inside the copied graph. MDN’s method reference also illustrates support for circular references.

Keep callbacks with the component that owns them#

When the consumer only needs job parameters, pass the projected data. When it needs a runnable Job, create a new instance from validated fields using an application-defined constructor or factory, then attach the appropriate local callback. The fixture deliberately stops at the data copy: it does not assume how another process or component should execute the job.

Do not treat the original callback as portable data. Its captured state and side effects belong to your application. Keeping the original function reference may be acceptable for a same-process wrapper, but that is a different contract from obtaining an independent data clone.

Object spread does not repair that contract. The fixture proves that { ...job } retains the same callback reference and the same nested options object. A shallow wrapper may be useful, but it does not give the isolation checked for the projected payload.

JSON round-tripping is a separate serialization choice. It does not preserve every JavaScript value; for example, undefined object properties disappear from JSON. Replacing a failing clone with JSON serialization can therefore change the payload instead of preserving it. Define the accepted fields and types before choosing either operation.

Narrow a real failing payload without changing its meaning#

Start at the call site and identify whether it receives a data record or an instance carrying behavior. For a known plain record, reduce the failing input to selected fields and nested sections until the failure is reproducible. Then replace the accidental instance or callback with the data the consumer actually needs, and assert both the outgoing shape and the required independence.

Avoid treating a generic property walker as a complete cloneability validator. The fixture covers ordinary objects, one ordinary class, and a Date; it does not exercise proxies, accessors, DOM nodes, framework reactive state, transferable buffers, or worker message delivery. A getter or proxy can introduce behavior during inspection. Diagnose those types using their own platform documentation and a reproduction in the actual runtime.

These checks were executed in Node.js, not a browser or a deployed worker. Error message wording and platform object support remain environment-dependent. If the result must keep methods, test that requirement explicitly; if it only needs data, project the data and test that the copy can change independently.

References:

structuredClone DataCloneError: Separate Data from Functions
https://laplusda.com/en/posts/structuredclone-datacloneerror-functions/
Author
Zero
Published at
2026-10-15
License
CC BY-NC-SA 4.0