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

Is structuredClone faster than JSON.parse(JSON.stringify())?

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 structuredClone fast. 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; structuredClone is 2.7x faster on a 10,000-element numeric array.
  • The manual copy silently corrupts Date, Map, Set and typed arrays into plain objects. JSON silently turns a Date into a string.
  • Only structuredClone survives a circular reference. JSON throws TypeError, the manual copy throws RangeError.

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.