TL;DR
structuredClone is the modern built-in — available in Node.js 17+, all major browsers, Deno, and Bun. It handles Map, Set, Date, ArrayBuffer, circular references, and many other types correctly, with zero dependencies. rfdc (Really Fast Deep Clone) is the fastest library option — 3-5x faster than JSON round-trip, handles dates, and has minimal footprint. klona is the smallest library — 700B, handles all common JavaScript types, ESM-native. In 2026, reach for structuredClone first. Only add a library if you need performance-critical cloning in hot paths, or if you need Node.js 16 support.
Key Takeaways
structuredClone: Built-in (0KB) — Node.js 17+, handles Map/Set/Date/circular refs, W3C standard- rfdc: ~10M weekly downloads — fastest library, 3-5x faster than JSON, ~500B
- klona: ~5M weekly downloads — 700B, excellent type coverage, ESM-native
JSON.parse(JSON.stringify(obj))dropsundefined, corruptsDate, ignoresSymbol— avoid for deep copy- structuredClone does NOT clone functions, class instances lose their prototype
- Use rfdc or klona only in performance-critical hot paths — structuredClone is fine for most cases
The Deep Copy Problem
// Shallow copy — WRONG for nested objects:
const original = { user: { name: "Royce", settings: { theme: "dark" } } }
const shallow = { ...original }
shallow.user.settings.theme = "light"
console.log(original.user.settings.theme) // "light" — MUTATED!
// JSON round-trip — common but broken:
const jsonCopy = JSON.parse(JSON.stringify(original))
// JSON round-trip limitations:
JSON.parse(JSON.stringify({
date: new Date(), // → string (Date lost)
undef: undefined, // → dropped entirely
fn: () => {}, // → dropped entirely
map: new Map([["a", 1]]), // → {} (empty object)
set: new Set([1, 2, 3]), // → {} (empty object)
regex: /pattern/g, // → {} (empty object)
num: NaN, // → null
inf: Infinity, // → null
}))
// Only works correctly for plain JSON-compatible data
structuredClone (built-in)
Built into JavaScript runtimes — no install needed:
Basic usage
// Available in Node.js 17+, all modern browsers, Deno, Bun:
const original = {
name: "pkgpulse",
config: {
features: ["ssr", "analytics"],
settings: { theme: "dark", locale: "en" },
},
createdAt: new Date("2024-01-01"),
tags: new Set(["typescript", "nextjs"]),
meta: new Map([["version", "1.0.0"], ["author", "Royce"]]),
}
const clone = structuredClone(original)
// Mutations don't affect original:
clone.config.settings.theme = "light"
clone.config.features.push("ai")
console.log(original.config.settings.theme) // "dark" — untouched
console.log(original.config.features.length) // 2 — untouched
// Types are preserved:
console.log(clone.createdAt instanceof Date) // true (not a string)
console.log(clone.tags instanceof Set) // true
console.log(clone.meta instanceof Map) // true
Circular reference support
// structuredClone handles circular references — JSON.parse/stringify throws:
const obj: Record<string, unknown> = { name: "circular" }
obj.self = obj // Circular reference
// JSON.parse(JSON.stringify(obj)) // TypeError: Converting circular structure to JSON
const clone = structuredClone(obj)
// Works! clone.self === clone (internal reference preserved)
console.log(clone.self === clone) // true
What structuredClone does NOT support
// Functions are NOT cloned — throws DataCloneError:
// structuredClone({ fn: () => {} })
// → DataCloneError: () => {} could not be cloned
// Class instances lose their prototype:
class Packet {
constructor(public data: string) {}
serialize() { return this.data }
}
const packet = new Packet("hello")
const clone = structuredClone(packet)
console.log(clone instanceof Packet) // false — it's a plain object
// clone.serialize // undefined — methods are gone!
// DOM nodes, WeakMap, WeakSet, Proxy — not supported
// Error objects lose their class too
// Workaround for classes — implement custom clone:
class Packet {
constructor(public data: string) {}
clone() { return new Packet(this.data) }
}
Transfer (move, not copy)
// structuredClone can TRANSFER ownership (zero-copy for ArrayBuffer):
const buffer = new ArrayBuffer(1024)
const view = new Uint8Array(buffer)
view[0] = 255
const clone = structuredClone(buffer, { transfer: [buffer] })
// buffer is now detached — zero-copy transfer
console.log(buffer.byteLength) // 0 — detached!
console.log(clone.byteLength) // 1024 — new owner
// Useful for Worker thread message passing without copying large buffers
rfdc
rfdc — Really Fast Deep Clone:
Basic usage
import rfdc from "rfdc"
// Create a clone function (configure once):
const clone = rfdc()
const original = {
name: "react",
nested: { deep: { value: 42 } },
date: new Date("2024-01-01"),
arr: [1, 2, { three: 3 }],
}
const copy = clone(original)
copy.nested.deep.value = 99
console.log(original.nested.deep.value) // 42 — untouched
console.log(copy.date instanceof Date) // true — Date preserved
Configuration options
import rfdc from "rfdc"
// Default — fastest, no circular reference support:
const clone = rfdc()
// With circular reference support (slightly slower):
const cloneCircular = rfdc({ circles: true })
const obj: any = { a: 1 }
obj.self = obj
const copy = cloneCircular(obj)
// Works! copy.self === copy
// Proto — copy prototype (default: false):
const cloneWithProto = rfdc({ proto: true })
class Config {
value = 42
get double() { return this.value * 2 }
}
const config = new Config()
const copied = cloneWithProto(config)
// copied.double still works — prototype methods copied
Performance comparison
// rfdc is consistently the fastest deep clone library:
// (100K iterations of a moderately nested object)
// JSON round-trip: ~500ms (baseline)
// lodash cloneDeep: ~400ms (1.25x faster than JSON)
// structuredClone: ~350ms (1.4x faster than JSON)
// klona: ~200ms (2.5x faster than JSON)
// rfdc: ~150ms (3.3x faster than JSON)
// rfdc (with proto:true): ~180ms (2.8x faster than JSON)
// When to care about this: streaming pipelines, game loops,
// high-frequency event processing — not typical CRUD operations
Hot path usage
import rfdc from "rfdc"
const clone = rfdc()
// Example: package data pipeline processing 10K packages/sec
function processPackageStream(packages: Package[]) {
return packages
.map(clone) // Clone before mutation (rfdc is fast here)
.map(normalizePackage) // Mutate the clone safely
.filter(isHealthy)
}
// In React — cloning state for immutable updates:
function reducer(state: AppState, action: Action): AppState {
const next = clone(state)
// ... apply action mutations to next
return next
}
klona
klona — 700B deep clone library:
Basic usage
import { klona } from "klona"
// Or: import klona from "klona/full" // Includes TypedArrays, DataView, etc.
const original = {
name: "typescript",
config: {
strict: true,
paths: { "@/*": ["src/*"] },
},
date: new Date(),
arr: [1, [2, [3]]],
map: new Map([["key", "value"]]),
set: new Set([1, 2, 3]),
}
const copy = klona(original)
copy.config.strict = false
copy.date.setFullYear(2020)
copy.arr[1][0] = 99
console.log(original.config.strict) // true — untouched
console.log(original.date.getFullYear()) // current year — untouched
console.log(original.arr[1][0]) // 2 — untouched
klona variants
// klona ships multiple variants for different use cases:
// klona/json — fastest, JSON types only (no Date/Map/Set):
import { klona } from "klona/json"
// klona/lite — handles Date + RegExp but not Map/Set/TypedArray:
import { klona } from "klona/lite"
// klona (default) — handles Date, Map, Set, RegExp, DataView:
import { klona } from "klona"
// klona/full — everything including TypedArray, ArrayBuffer, DataView, Symbol:
import { klona } from "klona/full"
ESM and tree-shaking
// klona is ESM-native — works well with bundler tree-shaking:
// Only import what you use:
import { klona } from "klona" // ~700B
import { klona } from "klona/json" // ~200B (just JSON types)
// Compare to lodash cloneDeep:
// import { cloneDeep } from "lodash" // ~15KB (pulls in lodash internals)
// import cloneDeep from "lodash/cloneDeep" // ~5KB (better but still heavy)
Feature Comparison
| Feature | structuredClone | rfdc | klona |
|---|---|---|---|
| Install required | ❌ Built-in | ✅ | ✅ |
| Bundle size | 0KB | ~500B | ~700B |
| Map/Set | ✅ | ❌ | ✅ |
| Date | ✅ | ✅ | ✅ |
| RegExp | ✅ | ❌ | ✅ |
| TypedArray | ✅ | ✅ | ✅ (full) |
| Circular refs | ✅ | ✅ (opt-in) | ❌ |
| Functions | ❌ (throws) | ❌ | ❌ |
| Class prototype | ❌ | ✅ (opt-in) | ❌ |
| Performance | Fast | ⚡ Fastest | Very fast |
| Node.js 16 | ❌ | ✅ | ✅ |
| ESM | N/A | ✅ | ✅ |
When to Use Each
Use structuredClone (built-in) if:
- Node.js 17+ / modern browser (2022+) — this covers most projects in 2026
- You need Map, Set, Date, circular reference support
- Zero dependencies is a priority
- General-purpose deep cloning of state, config, data
Use rfdc if:
- Performance-critical hot paths (game loops, streaming data pipelines)
- Need the fastest possible clone with minimal footprint
- Node.js 16 support needed
- Payload is plain objects + arrays + Dates (no Map/Set needed)
Use klona if:
- Node.js 16 support needed but you want Map/Set/RegExp support
- Prefer a library with multiple precision variants (klona/json vs klona/full)
- ESM-first codebase with tree-shaking
Avoid these patterns:
// JSON round-trip — loses types, don't use:
const bad = JSON.parse(JSON.stringify(obj))
// lodash cloneDeep — 5KB+ for a single utility:
import cloneDeep from "lodash/cloneDeep" // Use structuredClone instead
// Object.assign — shallow only:
const shallow = Object.assign({}, nested) // nested props still shared
// Spread — also shallow:
const stillShallow = { ...nested } // Same problem
TypeScript and Immutable Data Patterns
Deep copying is often used in conjunction with immutable data patterns in TypeScript. When you have a readonly object type and want to produce a modified copy, a deep clone gives you a mutable copy to work with before returning an immutable version. For Redux-style state management, libraries like Immer provide a better ergonomic alternative to manual deep cloning: Immer uses JavaScript Proxies to track mutations on a draft object and produces a structurally shared immutable result, cloning only the changed nodes. For simpler use cases without Immer's complexity, structuredClone followed by Object.freeze (recursively) achieves deep immutability. TypeScript's as const assertion makes object literals readonly at the type level but does not prevent runtime mutation — deep cloning before passing objects to untrusted code remains necessary even when TypeScript types say readonly.
Performance Benchmarks in Context
The benchmark numbers comparing rfdc, klona, and structuredClone require context to interpret correctly. Most benchmarks measure tight loops on small synthetic objects, which overrepresent the CPU cost of clone initialization and underrepresent the cost of object property traversal. On realistically sized application state objects — nested 5-10 levels deep with 50-100 properties — the performance gap narrows significantly. structuredClone's C++ implementation (Node.js implements it natively, not in JavaScript) performs competitively against rfdc for objects that mix plain values, Dates, Maps, and Sets because rfdc must handle those types in slower JavaScript code. For purely plain objects (no Date, Map, Set, RegExp) with high nesting depth, rfdc's optimized traversal algorithm wins consistently. Profile your specific object shapes rather than relying on microbenchmarks when the choice matters for your performance budget.
Edge Cases and Production Gotchas
Several structuredClone edge cases cause production surprises. Attempting to clone objects with non-serializable properties throws a DataCloneError — this includes functions, class instances with methods, DOM nodes, WeakMap, and WeakSet. If your application state accidentally contains a function reference (common when storing event handlers or callbacks in state objects), structuredClone throws rather than silently omitting the property. rfdc silently omits functions and handles them as null, which may or may not be what you want. klona similarly ignores functions. Another edge case: Symbols as property keys are not preserved by any of the three tools — they're silently dropped. If your codebase uses Symbol-keyed properties for metadata or internal framework markers, deep cloning will break that functionality.
When to Avoid Deep Cloning
The prevalence of deep cloning in JavaScript codebases reflects a broader pattern of defensive copying that is often unnecessary. If you are passing an object to a function you wrote yourself and that function does not mutate the input, you do not need to clone — pass the reference. If you are producing a derived value (filtering an array, picking properties from an object), you are already creating a new object through the transformation itself — no additional clone needed. Deep cloning is genuinely necessary in three scenarios: when you need to decouple an object from its source to prevent mutation side effects, when you're passing objects across process or thread boundaries (worker_threads, child_process), and when implementing undo/redo functionality where you need snapshots of previous state. Over-application of deep cloning adds unnecessary allocation pressure and can be a meaningful performance regression in tight loops.
Alternatives for Specific Use Cases
Some specific deep-copy scenarios have better specialized tools. For React state management, Immer's draft/produce pattern is both more ergonomic and more performant than cloning entire state trees since it only copies modified branches (structural sharing). For serializing data for network transmission or storage, JSON.stringify + JSON.parse is the correct tool — not because it's a good deep clone (it isn't), but because serialization and deep copying are distinct operations and the JSON round-trip is the right primitive for network/storage serialization. For database query results that need modification, map them through a transform function that creates new objects with your modifications rather than cloning the raw result objects. For configuration objects that need to be merged, spread operators or Object.assign work fine for shallow configuration merging without the full clone overhead.
TypeScript Utility Types for Safe Mutation Patterns
TypeScript's Readonly<T> and ReadonlyArray<T> utility types help document intent but do not prevent mutations at runtime. A deep clone gives you a genuinely mutable copy from a readonly source — the runtime behavior changes even if the types don't always reflect this. The pattern const mutable = structuredClone(readonly) as Mutable<typeof readonly> (where Mutable is a custom utility type that removes all readonly modifiers) signals to TypeScript that the cloned object is intentionally mutable. For server-to-client data flow in Next.js or similar frameworks, Server Components return data that should be treated as immutable on the client — if client components need to derive modified versions, deep clone the server data before mutation rather than mutating props or context values, which violates React's component model and causes subtle bugs when the same data is shared across multiple component instances.
Worker Thread Communication and Structured Clone
Node.js worker threads use the structured clone algorithm internally for message passing via postMessage. This means structuredClone's type support matrix — what can and cannot be transferred — directly determines what data you can send between worker threads. ArrayBuffers can be transferred (zero-copy) rather than cloned by including them in the transfer list, which is critical for performance when passing large typed arrays to worker threads for CPU-intensive processing. Functions cannot cross worker thread boundaries at all since they cannot be structured-cloned. Understanding this constraint is important when designing worker-based architectures: worker threads must communicate via plain data, not function callbacks. For libraries like Piscina (worker thread pool) and threads.js, the data you pass in must be structurally cloneable, which rfdc or klona can validate locally before the cross-thread send avoids confusing DataCloneError exceptions at runtime.
Compare utility and data processing packages on PkgPulse →
See also: AVA vs Jest and ohash vs object-hash vs hash-wasm, acorn vs @babel/parser vs espree.