Back to Knowledge Hub
Fingerprint

structuredClone vs JSON: What a Deep Copy Actually Keeps

Run structuredClone and a JSON round trip over dates, maps, sets, NaN, BigInt, cycles and shared references to see what each keeps, changes, rejects or silently drops.

BotBrowser Team

Documentation

Want the structured docs for Fingerprint?

This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.

BotBrowser supports replaying an authorized, same-origin fixture under a declared profile and keeping bounded observations about how a page copies an object, but it does not change how a runtime implements the structured-clone algorithm, cannot make a function or a symbol cloneable, and does not turn a copy result into an identity signal. This article runs structuredClone and a JSON round trip over the same values, then records which types each keeps, which numbers it changes, which values it rejects or silently drops, and how shared references, cycles and prototypes behave.

TL;DR

  • structuredClone keeps Date, Map, Set and RegExp as those types; a JSON round trip turns a Date into a string, and a Map, Set or RegExp into {}.
  • A JSON round trip turns NaN and Infinity into null and -0 into 0; structuredClone keeps all three, including the sign of -0.
  • structuredClone throws DataCloneError on a function or a symbol; JSON silently drops a function property.
  • structuredClone keeps a BigInt and, with the transfer option, moves an ArrayBuffer; JSON.stringify throws on a BigInt.
  • structuredClone preserves shared references and cycles; a JSON round trip duplicates a shared reference and throws on a cycle.
  • Neither preserves a class prototype, so a cloned instance loses its methods unless the prototype is restored by hand.

Contents

A language-neutral diagram: one source value feeds structuredClone and a JSON round trip; structuredClone keeps the type and shared references and throws on functions, while the JSON path turns Map and Set into empty objects and changes special numbers.

Types that survive one copy and not the other

The two paths copy different parts of a value. structuredClone implements the HTML structured-clone algorithm, which has explicit cases for the platform types below; a JSON round trip only knows how to serialise what JSON.stringify supports and how to rebuild what JSON.parse returns:

structuredClone({d:new Date(0)}) -> {d: Date(1970-01-01T00:00:00.000Z)}
JSON round trip of Date -> {d: "1970-01-01T00:00:00.000Z"}
structuredClone({m:new Map([[1,"a"]])}) -> {m: Map(1=>"a")}
JSON round trip of Map -> {m: {}}
structuredClone({s:new Set([1,2])}) -> {s: Set(1, 2)}
JSON round trip of Set -> {s: {}}
structuredClone(/ab+/gi) -> RegExp(/ab+/gi)
JSON round trip of RegExp -> {}

Four differences in four rows. A Date becomes a string through JSON, so a value that was a Date before the round trip is a string afterward and any .getTime() call fails. A Map becomes an empty object, so every entry is gone with no error. A Set becomes an empty object, so every member is gone with no error. A RegExp becomes an empty object, losing its pattern and flags.

The silence is the dangerous part. Each of those four JSON results is a valid value that type-checks as an object, so code that only checks typeof sees no problem. The loss appears later, when the code calls a Map method on {} or does arithmetic on a date string. That is why a deep copy has to be chosen by what the value contains, not by what is convenient: a JSON round trip is correct only for values built from JSON's own types.

Typed arrays show the same pattern with a more confusing result, because JSON produces an object that looks like an array without being one:

structuredClone typed array kept: true values=1,2,3
JSON typed array kept: false shape={"0":1,"1":2,"2":3}

structuredClone returns a real Uint8Array with the same three values, so instanceof passes and every typed-array method works. The JSON version is a plain object with numeric keys, so instanceof Uint8Array is false, byteLength is undefined, and a loop that writes through an index still assigns plain properties. A caller who serialises binary data with JSON and reads it back gets a structure that logs identically under JSON.stringify and behaves completely differently under any arithmetic, so a comparison of serialised output cannot detect the change. Only a type check or a length check on the result shows it.

The object shape is preserved only for values that JSON already understands. Plain objects, arrays, strings, finite numbers, booleans and null survive a round trip with their structure intact, which is exactly the subset JSON was designed for. Everything else either converts, drops or throws. A schema that spells out the permitted types is therefore the cheapest way to choose a copy method: if the schema contains only JSON types, a round trip is safe and predictable; if it names a Date, a Map or a typed array, structuredClone is the only path that keeps it.

Independence is the one property both paths share, and it is worth confirming rather than assuming. A fresh clone and a fresh JSON copy each get their own nested objects and arrays, so mutating one does not write through to the source:

clone independent: list=true nested=true date=true

After pushing to the cloned array, changing a nested property and setting a new time on the cloned date, the source still reports a three-element list, the original nested value and the original timestamp. So a deep copy really is deep for the values it keeps; the differences above are about what it keeps, not about whether it isolates the copy from the source.

Numbers and BigInt

JSON's number rules are narrower than JavaScript's, and a round trip silently changes three values that look representable:

structuredClone({n:NaN,i:Infinity,z:-0}) -> {n: NaN, i: Infinity, z: -0}
JSON round trip of NaN/Infinity/-0 -> {n: null, i: null, z: 0}
Object.is(JSON -0, -0) -> false
Object.is(structuredClone -0, -0) -> true

NaN and Infinity become null through JSON, because JSON has no literal for them, and a caller who round trips a value with either gets null back with no warning. The -0 case is subtler: the JSON result is numerically equal to -0 under ===, so a naive equality check passes, but Object.is distinguishes the sign and reports false, while structuredClone keeps the sign and reports true. Code that stores a negative zero to mean something (a signed zero in a computation, for example) loses that signal through JSON without any error.

BigInt splits the two paths even more sharply. structuredClone handles it, and JSON.stringify throws on it:

structuredClone(10n) -> BigInt(10)
JSON.stringify(10n) -> THREW TypeError

The TypeError from JSON.stringify is the one loud failure in the whole comparison, and it is worth noting because it is the exception. A value that contains a BigInt cannot cross a JSON boundary at all, while it crosses structuredClone unchanged. A team that moved to BigInt identifiers and kept a JSON-based deep copy will hit this the first time such an identifier is copied.

The numeric changes above are the same class of problem covered in JavaScript number precision and cross-runtime portability: a value can be numerically equal under === and still differ under Object.is, and a copy path that serialises is where that difference becomes permanent.

Values that are rejected or silently dropped

For the values that have no copy representation, the two paths diverge in the worst possible way: one rejects, the other drops:

structuredClone(()=>1) -> THREW DataCloneError
structuredClone(Symbol("x")) -> THREW DataCloneError
JSON round trip of function property -> {v: 1}
structuredClone({u:undefined}) -> {u: undefined}
JSON round trip of {u:undefined} -> {}
JSON round trip of [1,,3] -> [1, null, 3]

structuredClone raises DataCloneError for a function or a symbol, so the failure is immediate and at the point of the copy. A JSON round trip drops the function property silently: the result has v: 1 and no f, so a caller who expected the method to survive gets a plain object with the method missing. An undefined property is kept by structuredClone and dropped by JSON, and array holes are replaced by null through JSON.

The general shape should be recorded as a rule: structuredClone fails loudly on a value it cannot represent, while JSON fails silently by dropping or substituting. For a validation path, the loud failure is easier to catch and to fix. For a path that a value must always survive, the silent drop is the hazard, because the copy looks successful while the data is gone.

Shared references and cycles

A deep copy is not only about types; it is also about the graph. When the same object appears twice in the source, the two copies behave differently:

structuredClone shared reference identical: true
JSON shared reference identical: false

structuredClone keeps the reference: if a and b point to the same object before the copy, they still point to the same (new) object after it, so the graph shape is preserved. A JSON round trip duplicates the shared object, because serialising writes it twice and parsing builds two separate objects, so a === b becomes false. That difference matters for any value whose semantics depend on aliasing, such as a node shared between two parents, and the JSON copy silently breaks the alias.

Cycles are the sharper case. structuredClone handles a self-referencing object and keeps the loop intact, while a JSON round trip throws:

structuredClone(cycle) -> ok, self===clone: true
JSON round trip of cycle -> THREW TypeError

The clone's self points to the clone, so the cycle survives. JSON.stringify throws on a cyclic structure because it cannot represent infinite nesting, and the error is a TypeError. So a value with a cycle is copyable by structuredClone and not by JSON at all, which is the opposite of the shared-reference case where JSON copies but changes the shape.

The cycle result also carries a lesson about the tools that inspect a clone. A recursive describe function that walks every property of a cyclic object without tracking what it has already visited will recurse until the stack overflows, so a fixture that prints a cloned cyclic graph has to stop at a visited set rather than following each reference blindly. The clone itself is fine; the reporting code is what fails. That distinction matters when a test reports a RangeError on a cyclic value: the error may come from the assertion helper rather than from the copy, and the fix belongs in the helper, not in the copy call.

Prototypes, transferables and what still needs care

Neither path preserves a class prototype. A cloned instance loses its methods and is a plain object afterward:

structuredClone prototype is Point: false
structuredClone has double method: undefined

The cloned value has the instance fields but not the prototype, so instanceof Point is false and calling double() fails. A team that clones domain objects through structuredClone still has to restore the prototype, usually by constructing a fresh instance from the cloned fields or by setting the prototype explicitly. The copy is a structural copy, not a semantic one.

The transfer option is what makes structuredClone more than a deep copy. Passing a buffer in the transfer list moves it rather than copying it, and the source buffer becomes detached:

transfer: source byteLength=0 clone byteLength=8

The source reports a length of zero because ownership moved to the clone, so the source can no longer be used. That is a deliberate optimisation for a large buffer that is being handed from one place to another, and it is also a footgun: a caller who transfers a buffer and then reads from the source gets an empty view, with no error. A copy reading should record whether a transfer happened, because a transfer changes the source as well as producing the clone.

The transfer rule also decides whether a copy is a copy at all. Without a transfer list, a buffer is duplicated like any other value, so the source keeps its bytes and the clone gets its own, at the cost of doubling the memory for the duration of the copy. With a transfer list, ownership moves and the total memory stays flat, which matters for a buffer that is large enough to be the reason the copy was slow. So the same call has two very different profiles depending on one option, and a fixture that measures only the clone cannot tell which happened; it has to record the source length afterward.

Worker messages use the same structured-clone algorithm, so every result in this article carries over to data posted to a worker and read in the worker. The difference is that a worker boundary also applies the transfer rules automatically for a buffer that is listed as transferable, and a buffer that is posted twice without a transfer is copied twice. A team that moves a large buffer between the page and a worker and then reads from the page side will find an empty buffer, exactly as in the in-memory transfer above, and the failure looks like corrupted data rather than a transfer that emptied the source.

There is one more asymmetry worth recording because it breaks comparison code. structuredClone keeps undefined properties and JSON drops them, so two objects that look equal under a JSON comparison can differ under a structured clone:

structuredClone({u:undefined}) -> {u: undefined}
JSON round trip of {u:undefined} -> {}

A test that compares two values by first passing both through JSON will treat an object with u: undefined and an object without u as equal, because both serialise to {}. The same test run on structured clones will see one object with a key and one without. So a comparison helper built on JSON is stricter about some types and looser about others than the value model actually allows, and a fixture should compare the cloned values directly rather than round-tripping them through JSON to compare.

Record a copy reading

A copy reading that survives a review states what the copy keeps and what it drops. Six fields cover the observable part:

  1. The copy method and the runtime, so a reader knows whether the algorithm is structured-clone or JSON.
  2. The list of value types in the source, and which of them are still the same type after the copy.
  3. The special numeric values before and after, including NaN, Infinity and the sign of -0.
  4. Whether a function, a symbol or a BigInt was present, and whether the copy rejected it or dropped it.
  5. Whether the source had shared references or cycles, and whether they survived.
  6. For a buffer, whether it was transferred, and the source length after the transfer.

What such a reading cannot show is anything about a copy across a worker boundary or into persistent storage. A value can clone cleanly in memory and still fail to serialise to a database, and a message to a worker uses the same structured-clone algorithm but with its own transfer rules. For the wider question of how a page's data model interacts with what it reports, see browser storage model and URL parsing and search params, which covers another place where a value does not survive a naive round trip.

Two habits keep the reading honest over time. First, record the type list and the shared-reference result, not only whether the copy returned without throwing, because a copy that succeeds while dropping a Map is a failure the throw-check misses. Second, include at least one -0, one shared reference and one cycle in a fixture that relies on a deep copy, so the silent substitutions and the alias break cannot pass a green suite.

Public sources

Practical conclusion

Choose the copy by what the value contains: use structuredClone for Date, Map, Set, RegExp, BigInt, shared references and cycles, and use a JSON round trip only for values built from JSON's own types. Expect structuredClone to fail loudly on a function or a symbol and JSON to drop it silently, and expect JSON to turn NaN and Infinity into null and -0 into 0. Restore a class prototype by hand after any structural copy, and record whether a buffer was transferred, so the source that went empty is not a surprise.

#structuredClone#JSON#Deep Copy#DataCloneError#Transferables

This article is part of the guide: What Is a Browser Fingerprint?

Take BotBrowser from research to production

The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.