cf.completefrontend
ALGORITHMS IN FRONTEND WORK · CORE

Deep clone and deep equal

Copy or compare nested objects and arrays by walking into every level, remembering objects already seen so loops don't run forever.

Time O(n)Space O(n)4 practice problemsStep-through picture

The idea

In JavaScript, a === b on two objects only checks whether they are the same object in memory. Two separate objects with the same contents are not ===. And { ...settings } copies only the top level: nested objects inside are still shared, so changing the copy's nested values changes the original too.

A deep clone makes a new object for every object and array inside, all the way down. A deep equal check compares two values the same way: same kind (array or object), same keys, and deep-equal values under each key. Both are recursive: they call themselves on each child value.

Objects can point back at themselves, like a tree node with a parent link. A naive recursion then goes around the loop forever. The fix is a seen map from each original object to its copy. Before copying an object, check seen: if it is there, reuse the copy. That also keeps shared references shared in the copy.

An everyday example

Copying a set of linked notebooks

You must copy a set of notebooks where pages say "see notebook B". You copy notebook A, and when a page points to B, you copy B too. But B has a page saying "see notebook A". If you just follow pointers, you copy A again, then B again, forever. So you keep a list: "original A is copy A2, original B is copy B2". When a pointer leads to a notebook already on the list, you point to its copy instead of copying again.

How the example maps to the code
In the exampleIn the code
A notebookAn object or array, value
The list of originals and their copiesseen, a Map
Already on the listseen.has(value), so return seen.get(value)
Writing the list entry before copying the pagesseen.set(value, copy) before the loop
Copying each pagecopy[key] = deepClone(value[key], seen)

From the slow way to deep clone and deep equal

  1. Start with the obvious copies. { ...settings } and Object.assign copy only the top level, so copy.theme is the very same object as settings.theme, and changing one changes the other. JSON.parse(JSON.stringify(settings)) copies every level, but it drops undefined values and functions, turns a Date into a string, and throws on data that points back at itself.
  2. A real copy makes a new object for every object inside, all the way down. That is a recursion: a primitive like a number or string is returned as is, since it can't be changed anyway, and an object or array becomes a new empty one whose keys are filled by copying each child. For data shaped like a tree, that's enough.
  3. But app data isn't always a tree. A document can list the same user as owner and as editors[0]. The simple recursion copies that user twice, so in the copy the two are different objects, and the shape has changed. Worse, if the user points back at the document, the recursion goes around the loop until the call stack overflows.
  4. Both problems have one fix: remember what you've already copied. Keep seen, a Map from each original object to its copy. Check it before copying, and record the new copy before filling it, so a loop back finds the half-built copy. That recursion with a memory is deep clone; deep equal is the same walk over two values at once, comparing kinds, key counts, and each key's values.

How it works

  1. If value isn't an object (a number, string, boolean, null, or undefined), return it as is. Primitives can't be changed, so sharing them is safe.
  2. If seen already has value, return its copy. This handles loops and shared objects.
  3. Make an empty copy, an array if value is an array, otherwise a plain object.
  4. Record seen.set(value, copy) before copying children, so a child pointing back finds the copy.
  5. For each key of value, set copy[key] = deepClone(value[key], seen), then return copy.
  6. For deepEqual(a, b): return true if a === b; false if either isn't an object or one is an array and the other isn't; otherwise compare key counts and then each key's values with deepEqual.

Watch it run

This picture runs the code below on a real input. Press Step to move one step at a time, or Play to watch. The highlighted line is the one that just ran. Change the input to see other cases.

Loading the picture…
See every step at onceThe same run, laid out side by side
Drawing every step…

The code

Point at a note to light up the lines it explains.

  1. function deepClone(value, seen = new Map()) {
  2. if (value === null || typeof value !== "object") return value;
  3. if (seen.has(value)) return seen.get(value);
  4. const copy = Array.isArray(value) ? [] : {};
  5. seen.set(value, copy);
  6. for (const key of Object.keys(value)) {
  7. copy[key] = deepClone(value[key], seen);
  8. }
  9. return copy;
  10. }
  11. function deepEqual(a, b) {
  12. if (a === b) return true;
  13. if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
  14. if (Array.isArray(a) !== Array.isArray(b)) return false;
  15. const keysA = Object.keys(a);
  16. const keysB = Object.keys(b);
  17. if (keysA.length !== keysB.length) return false;
  18. return keysA.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]));
  19. }

How fast it is

TimeO(n)
Extra memoryO(n)

Each object, array, and key is handled once. The seen map and the recursion depth can grow with the number of objects.

  • Best caseO(1)

    value is a primitive, like 42, so the first line returns it. For deepEqual, the two values are the same reference or have different key counts at the top.

  • Average caseO(n)

    A typical nested state object: every object, array, and key is handled once.

  • Worst caseO(n)

    Every value is an object with keys to visit. Shared objects and loops don't add work, because seen returns them at once.

Common mistakes

  • Using { ...obj } or Object.assign and calling it a deep copy. Nested objects are still shared.
  • Using JSON.parse(JSON.stringify(obj)). It drops undefined and functions, turns Date into strings, and throws on cycles.
  • Adding to seen after copying the children. Cycles then recurse forever.
  • Comparing only the keys of a in deepEqual. { a: 1 } would equal { a: 1, b: 2 }. Compare key counts too.

When to use it

Look for these clues in a problem:

  • The question asks for a copy that shares nothing with the original, or a check that two nested values have the same contents.
  • The data is nested objects and arrays of any depth.
  • It mentions cycles, shared references, or structuredClone.
  • Changes to a copy must not affect the original, as with immutable state updates.

Check yourself

Answer each one in your head first, then open it.

What happens if seen.set(value, copy) is moved after the loop over the keys?

A loop back to value isn't found in seen while its children are being copied, so a new copy starts, which loops again, until the call stack overflows.

In the trace, why is copy.owner === copy.editors[0] true?

Both point to ida in the original. The first visit made ida's copy and stored it in seen, and the second visit got that same copy back.

What does deepEqual([1, 2], { 0: 1, 1: 2 }) return, and why?

false. The keys and values match, but Array.isArray is true for one and false for the other, and the code treats that as a difference.

What does deepEqual({ a: undefined }, { b: undefined }) return?

false. Both have one key, but Object.hasOwn(b, "a") is false. Without that check, b.a would read as undefined and they would wrongly look equal.

Practice problems

Stuck on one? Tuto is in the playground too, and gives you hints, never the answer.

Not started

0 of 4 solved. Solve one to earn the bronze medal, half for silver, and all of them for gold.

Start practicing

Go further

You don't need these to start practicing. Open one when you want more.

Why it works

The reasoning that makes it always right

The key rule: seen maps every original object whose copy has been started to that one copy. The check seen.has(value) comes before making a copy, and seen.set(value, copy) comes right after, before any child is visited. So no object can be copied twice, even when a loop reaches it again while it's still being filled.

The copy has the same shape as the original. Each copy[key] is the clone of value[key]: primitives are equal, and objects map to their single copy. So if two places in the original point to the same object, the two places in the copy point to the same copy, and a loop in the original becomes a loop in the copy. Every object in the copy is new, so changing the copy never touches the original.

It ends because each object takes the "make a copy" path at most once, and each of its keys is visited once. Every other call returns at once, so the number of calls is at most the number of keys plus one. deepEqual is correct for tree-shaped data by the same reasoning, level by level: same kind, same number of keys, every key of a present in b with an equal value means both have exactly the same keys and values. It has no seen, so a loop makes it recurse forever.

Going deeper

3 more ideas

Why seen must be filled before the children

In node.parent.child === node, cloning node reaches parent, which reaches child, which is node again. If node were only added to seen after its children were copied, that inner visit wouldn't find it and would start another copy, forever. Adding it first means the inner visit finds the half-built copy and links to it, which is exactly right.

How fast it is

Every object and array is copied once and every key is visited once, so cloning is O(n) for n values in total. seen and the recursion use O(n) memory in the worst case. deepEqual also visits each value at most once per side, O(n).

What this simple version skips

It treats Date, Map, Set, and class instances as plain objects, which loses their type. The built-in structuredClone handles those, plus cycles, but it can't copy functions or DOM nodes. deepEqual here uses === for primitives, so NaN isn't equal to NaN; use Object.is if that matters. Comparing two cyclic structures needs a seen set of pairs, too.

Work it by hand

Every step in a table, with why

Clone a document whose owner is also its only editor, and whose owner points back at the document. Both repeats are found in seen.

ida = { name: "Ida", doc }, doc = { title: "Q3", owner: ida, editors: [ida] }; deepClone(doc)

StepCallvalueIn seen?seen.sizeReturnsWhy
1deepClone(doc)the doc objectno1doc copy (being filled)Not a primitive and not seen, so make copy = {} and record doc before looking at any key.
2.title"Q3"-1"Q3"A string is a primitive, so it is returned as is.
3.ownerthe ida objectno2ida copy (being filled)A new object: make an empty copy for ida and record it.
4.owner.name"Ida"-2"Ida"Another primitive.
5.owner.docthe doc objectyes2doc copyThe loop back to doc. It is already in seen, so the half-built copy is returned instead of starting again.
6.editorsthe editors arrayno3new array (being filled)Arrays get [] instead of {}, so Array.isArray stays true for the copy.
7.editors[0]the ida objectyes3ida copyThe same user again. seen returns the copy made at .owner, so the copy shares it too.
8return--3doc copyEvery key of doc is filled, so the top call returns its copy.

3 new objects were made, one per original object. In the copy, copy.owner === copy.editors[0] and copy.owner.doc === copy are both true, just like in the original, while copy !== doc.

Compared with other ways

When to pick something else
ApproachTimeExtra memoryChoose it when
Deep clone and deep equalO(n)O(n)You need a fully separate copy of plain objects and arrays, including loops and shared parts, or you're asked to write it in an interview.
structuredCloneO(n)O(n)In real code, when the data has no functions or DOM nodes. It handles more built-in types than a hand-written clone.
JSON.parse(JSON.stringify(value))O(n)O(n)Plain JSON data only: strings, numbers, booleans, null, arrays, and objects, with no loops, dates, or undefined.
Shallow copy { ...value }O(k)O(k)You only change top-level keys, as in immutable updates. It copies the k top-level keys and shares everything below.

Extra memory: O(n) for seen and the copy itself. The recursion uses one call per level of nesting, so a very deep chain uses a lot of call stack.

How the steps grow

1101001k10k100k1M116324864input size n
Steps as the input grows, for Deep clone and deep equal and the other approaches, on a scale where each line up is 10 times more steps.
  • Deep clone and deep equal, structuredClone, JSON.parse(JSON.stringify(value)), Shallow copy { ...value } O(n)
About how many steps for bigger inputs
Approachn = 10n = 1,000n = 1,000,000
Deep clone and deep equal101,0001 million
structuredClone101,0001 million
JSON.parse(JSON.stringify(value))101,0001 million
Shallow copy { ...value }101,0001 million

Inputs that need care

6 tricky inputs
A Date or a Map: deepClone(new Date(0))
Object.keys of a Date or a Map is empty, so the copy is a plain {} and the value is lost. Check instanceof Date, Map, and Set first and rebuild them, or use structuredClone.
Two different dates: deepEqual(new Date(0), new Date(5000))
Both have zero own keys, so it returns true. Compare getTime() for dates, and treat other built-in types the same way.
A sparse array: [1, , 3]
Object.keys skips the hole, but setting copy[2] makes the copy's length 3, so the hole is kept: the copy is [1, , 3] too.
Symbol keys and class instances
Object.keys skips symbol keys, so { [Symbol("s")]: 1, x: 2 } copies as { x: 2 }. A class instance becomes a plain object without its methods, because the copy is made with {}.
A chain of objects thousands of levels deep
Each level is one more recursive call. In a test with Node.js 22, a chain 5,000 objects deep already overflowed the call stack. An explicit stack of [original, copy] pairs avoids that.
Two structures with loops: x.self = x, y.self = y, then deepEqual(x, y)
deepEqual follows self forever and throws RangeError: Maximum call stack size exceeded. Add a seen map of pairs already being compared, and treat a repeat pair as equal.

Variations and follow-ups

Twists interviewers add

structuredClone

Browsers and Node.js have structuredClone(value), which copies loops, shared objects, Date, Map, Set, RegExp, and typed arrays. It throws a DataCloneError for functions and DOM nodes, and class instances come back as plain objects. It's the same copying used when you postMessage data to a worker.

Keeping more types

Before the generic branch, check the type: new Date(value) for dates, a new Map with cloned keys and values, a new Set with cloned items. To keep a class's methods, make the copy with Object.create(Object.getPrototypeOf(value)) instead of {}.

Deep equal with loops

Pass a Map from objects in a to the objects in b they're being compared with. If the pair (a, b) is already there, return true: if they differ, the first comparison of that pair will find out. This lets deepEqual finish on structures that point back at themselves.

Copying only what changes

Immutable updates, as in Immer or Redux reducers, copy only the objects on the path to the change and share everything else. That's cheaper than a deep clone, and a shallow === check can then tell which parts changed.

In an interview

Spot it, explain it, answer follow-ups
  • Spot it from "a copy that shares nothing with the original", "compare nested objects", "circular", or "cycle". If the data can point back at itself, seen is the main point of the question.
  • Explain it in order: primitives first, then the seen check, then make and record the copy, then fill it. Say out loud why recording happens before filling.
  • Test by hand: null, an array inside an object, a shared object used twice, and an object that points to itself. Check that the copy's shape matches, not just its values.
  • Expect the follow-ups "why not JSON.stringify?" (it loses types and fails on loops), "support Map, Set, and Date", and "make deepEqual work on loops" (a seen map of pairs).

Where frontend code uses it

The same idea in real UI code
  • structuredClone in browsers and Node.js deep-clones values with cycles, Map, Set, and Date; it is also the algorithm used when you postMessage data to a worker.
  • React compares props and dependencies with Object.is (shallow), not deep equality; libraries like Lodash's isEqual and test matchers like Jest's toEqual do deep comparison.
  • State libraries that require immutable updates, like Redux, rely on copies so that changes create new objects and shallow checks can detect them.

Related algorithms