Is structuredClone faster than JSON.parse(JSON.stringify())?
No. On a small flat object structuredClone runs at 1.28M ops/sec against JSON's 2.07M, and a five-line hand-written deep copy beats both at 8.71M — 6.8x faster than the native call. Use structuredClone anyway, because the fast one silently turns a Date into an empty object and stack-overflows on a c
structuredClone is not the fast option: on a small flat object it manages 1.28M ops/sec against JSON's 2.07M, and a five-line recursive copy does 8.71M, which is 6.8x faster than the native call. It is still the one to reach for, because the five-line version turns a Date into {} without telling you, and throws a stack overflow the moment your object contains a cycle. This article measures both halves of that trade.
The short answer
- Being implemented in C++ does not make
structuredClonefast. It runs a serialisation algorithm that handles cycles, Maps, Dates and typed arrays, and you pay for all of it on every call. - A hand-written recursive copy is 3.3x to 8.6x faster across every shape measured, because it only handles plain objects and arrays.
- Which built-in wins flips with shape. JSON is 1.3x to 1.6x faster on every object shape measured;
structuredCloneis 2.7x faster on a 10,000-element numeric array. - The manual copy silently corrupts
Date,Map,Setand typed arrays into plain objects. JSON silently turns aDateinto a string. - Only
structuredClonesurvives a circular reference. JSON throwsTypeError, the manual copy throwsRangeError.
What the numbers look like
Node 23.5.0, Apple M3. Operations per second, higher is better. Each cell is the median of 5 runs of the script below, each of which itself reports the median of 7 timed passes after a warm-up of up to 2,000 iterations.
| Shape | structuredClone | JSON round-trip | Manual recursion |
|---|---|---|---|
| Small flat, 8 keys | 1,282,360 | 2,071,913 | 8,708,590 |
| Nested config, 3 deep | 481,040 | 755,909 | 4,136,005 |
| 1,000 records | 2,922 | 3,862 | 18,248 |
| 10,000 numbers | 7,786 | 2,901 | 25,905 |
The manual column wins every row, by 6.8x, 8.6x, 6.2x and 3.3x respectively. structuredClone loses to JSON on all three object shapes and only wins on the numeric array.
Why is the native function the slow one?
Because it is doing more work, and the extra work is not optional. structuredClone implements the HTML structured clone algorithm, which maintains a memory of objects it has already visited so that a graph with cycles or shared references clones correctly. That bookkeeping runs whether or not your data has a single cycle in it.
It also dispatches on type. Every value is checked against the list of types the algorithm supports before being copied, so a Date comes out as a Date and a Float64Array comes out as a Float64Array. The manual version does one typeof check and recurses.
The numeric array row shows where that inversion pays off. On 10,000 numbers, structuredClone is 2.7x faster than JSON, because JSON has to format 10,000 floats into text and parse them back, while the clone algorithm copies a buffer. Text serialisation is the expensive part of the JSON approach, and it scales with how awkward your numbers are to print.
What does the fast version get wrong?
This is the part that decides the question, and it is easy to check.
| Input | structuredClone | JSON | Manual |
|---|---|---|---|
Date |
preserved | becomes a string |
becomes {} |
Map |
preserved | dropped | becomes {} |
Float64Array |
preserved | becomes an object | becomes an object |
| Circular reference | preserved | throws TypeError |
throws RangeError |
The two failures worth fearing are the silent ones. A Date passed through the manual copy comes back as {}, an empty object, and nothing anywhere throws. A Date passed through JSON comes back as "2026-01-02T03:04:05.000Z", a string that looks right in a log line and fails the moment something calls .getTime() on it.
The circular-reference failures are the safe kind, because they are loud. JSON.stringify throws TypeError: Converting circular structure to JSON. The manual recursion runs until it exhausts the stack and throws RangeError: Maximum call stack size exceeded. Both stop you. Neither hands you wrong data.
So when should you use each one?
Use structuredClone by default. It is correct for every shape, and at 1.28M ops/sec on small objects it is fast enough that the copy is almost never what your profiler points at. Reaching for a faster option before you have a measurement is optimising the wrong thing.
Use the manual copy when you have measured that cloning is hot and you control the shape. Configuration objects, game state made of numbers and strings, parsed JSON that you know contains no dates. Write a comment saying that, because the next person will add a Date field and there will be no error to trace.
Use JSON round-trip when you want the JSON semantics on purpose — dropping undefined, dropping functions, flattening a class instance to a plain object. It is a normalisation step that happens to also copy, not a copy that happens to be lossy.
Reach for a library if you need both correctness and speed on a known shape. A generated clone function for a fixed schema beats all three, because it does no type dispatch at all.
Does this hold in the browser?
The ordering does. structuredClone in browsers is the same algorithm behind postMessage, and it carries the same bookkeeping. The absolute numbers will differ with engine and hardware, which is why the script below prints your own rather than asking you to trust these.
One browser-specific note: structuredClone cannot clone functions, DOM nodes or class prototypes. It throws DataCloneError on a function, which again is the loud kind of failure. A class instance clones its fields and comes back as a plain object, losing its methods, which is the quiet kind.
Check it yourself
Save as clone-bench.cjs and run node clone-bench.cjs. Takes about 15 seconds.
const median = a => { const s = [...a].sort((x,y)=>x-y); return s[s.length>>1] }
const jsonClone = o => JSON.parse(JSON.stringify(o))
function manual(o) {
if (o === null || typeof o !== "object") return o
if (Array.isArray(o)) { const a = new Array(o.length)
for (let i=0;i<o.length;i++) a[i] = manual(o[i]); return a }
const r = {}; for (const k in o) r[k] = manual(o[k]); return r
}
const shapes = {
"small flat (8 keys)": () => ({a:1,b:"two",c:true,d:null,e:4.5,f:"six",g:7,h:"eight"}),
"nested config": () => ({server:{host:"localhost",port:8080,
tls:{cert:"a",key:"b",ciphers:["x","y","z"]}},
db:{url:"postgres://x",pool:{min:1,max:10}}, flags:{a:true,b:false,c:true}}),
"1000 records": () => ({rows: Array.from({length:1000},(_,i)=>
({id:i,name:"row"+i,score:i*1.5,ok:i%2===0}))}),
"10000 numbers": () => ({data: Array.from({length:10000},(_,i)=>i*0.5)}),
}
for (const [label, make] of Object.entries(shapes)) {
const obj = make()
const iters = label.includes("000") ? 300 : 20000
const run = fn => {
for (let w=0; w<Math.min(iters,2000); w++) fn(obj) // warm the JIT
const out = []
for (let p=0; p<7; p++) {
const t0 = performance.now()
for (let i=0;i<iters;i++) fn(obj)
out.push(iters / ((performance.now()-t0)/1000))
}
return median(out)
}
const fmt = n => Math.round(n).toLocaleString()
console.log(`${label.padEnd(22)} sC=${fmt(run(structuredClone))}` +
` JSON=${fmt(run(jsonClone))} manual=${fmt(run(manual))}`)
}
// The half that decides it.
const d = { when: new Date("2026-01-02T03:04:05Z") }
console.log("manual Date ->", JSON.stringify(manual(d).when)) // {}
console.log("JSON Date ->", typeof jsonClone(d).when) // string
console.log("sC Date ->", structuredClone(d).when instanceof Date) // true
The last three lines matter more than the table. A benchmark that only reports throughput will always tell you to write the manual copy, and it will be wrong four times out of the five shapes that show up in real code.
The offline sync layer in Simple Note Taker uses structuredClone for exactly this reason: note bodies carry timestamps, and a copy that turns those into strings produces conflicts that only appear after a merge.