TL;DR
fast-deep-equal is the fastest pure deep equality library — ~50M weekly downloads, handles most types (objects, arrays, Date, RegExp, typed arrays) but not Maps, Sets, or circular references. dequal is slightly smaller and handles Maps, Sets, and TypedArrays correctly, making it more complete. Lodash _.isEqual is the most comprehensive — handles circular references, non-enumerable properties, WeakMaps, and edge cases that other libraries miss — at the cost of bringing in Lodash. For React render optimization: fast-deep-equal. For complete equality including Maps/Sets: dequal. For edge cases and circular refs: _.isEqual.
Key Takeaways
- fast-deep-equal: ~50M weekly downloads — fastest, ~400B, handles most types except Map/Set/circular
- dequal: ~10M weekly downloads — ~620B, handles Map/Set/TypedArray, no circular refs
- lodash.isequal: ~15M weekly downloads — most complete, handles circular refs, ~2KB standalone
JSON.stringify(a) === JSON.stringify(b)— don't do this (key order sensitivity, loses type info)Object.is(a, b)— only for primitives and reference equality, not deep- The performance difference only matters at scale — for most use cases, any of these is fine
Download Trends
| Package | Weekly Downloads | Bundle Size | Map/Set | Circular Refs | TypedArrays |
|---|---|---|---|---|---|
fast-deep-equal | ~50M | ~400B | ❌ | ❌ | ✅ |
dequal | ~10M | ~620B | ✅ | ❌ | ✅ |
lodash.isequal | ~15M | ~2KB | ✅ | ✅ | ✅ |
What Each Library Handles
// Objects and arrays — all three handle these:
isEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }) // true ✅
// Dates:
isEqual(new Date("2026-01-01"), new Date("2026-01-01")) // true ✅ (all three)
// RegExp:
isEqual(/abc/gi, /abc/gi) // true ✅ (all three)
// Maps:
isEqual(new Map([["a", 1]]), new Map([["a", 1]]))
// fast-deep-equal: false ❌ (treats Map as object)
// dequal: true ✅
// _.isEqual: true ✅
// Sets:
isEqual(new Set([1, 2, 3]), new Set([1, 2, 3]))
// fast-deep-equal: false ❌
// dequal: true ✅
// _.isEqual: true ✅
// Circular references:
const a: any = {}; a.self = a
const b: any = {}; b.self = b
isEqual(a, b)
// fast-deep-equal: stack overflow ❌
// dequal: stack overflow ❌
// _.isEqual: true ✅
// TypedArrays (Uint8Array, Float32Array, etc.):
isEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3])) // true ✅ (all three)
fast-deep-equal
fast-deep-equal — the performance choice:
Usage
import equal from "fast-deep-equal"
// Or:
import deepEqual from "fast-deep-equal/es6" // Includes ES6 type support
// Basic objects:
equal({ a: 1, b: 2 }, { a: 1, b: 2 }) // true
equal({ a: 1 }, { a: 1, b: 2 }) // false
// Arrays:
equal([1, 2, 3], [1, 2, 3]) // true
equal([1, [2, 3]], [1, [2, 3]]) // true
// Nested:
equal(
{ user: { name: "Alice", roles: ["admin", "user"] } },
{ user: { name: "Alice", roles: ["admin", "user"] } }
) // true
// Primitives:
equal("hello", "hello") // true
equal(1, 1) // true
equal(null, null) // true
equal(undefined, undefined) // true
React memo with fast-deep-equal
import { memo } from "react"
import equal from "fast-deep-equal"
// Default React.memo uses shallow equality — fast-deep-equal for deep:
const PackageCard = memo(
function PackageCard({ package: pkg }: { package: Package }) {
return (
<div>
<h3>{pkg.name}</h3>
<p>Score: {pkg.healthScore}</p>
<ul>{pkg.tags.map((tag) => <li key={tag}>{tag}</li>)}</ul>
</div>
)
},
equal // Custom comparator
)
// Now PackageCard only re-renders if the deep value actually changes
// (not just reference changes from parent re-renders)
Use in useEffect dependencies
import { useEffect, useRef } from "react"
import equal from "fast-deep-equal"
// Prevent effect from running when object value hasn't changed:
function useDeepEffect(effect: () => void | (() => void), deps: unknown[]) {
const prevDeps = useRef<unknown[]>([])
if (!equal(prevDeps.current, deps)) {
prevDeps.current = deps
}
useEffect(effect, [prevDeps.current])
}
// Usage:
function PackageAnalyzer({ filters }: { filters: PackageFilters }) {
useDeepEffect(() => {
fetchPackages(filters) // Only runs when filters deeply change
}, [filters])
}
dequal
dequal — complete equality with Map/Set support:
Basic usage
import { dequal } from "dequal"
// Everything fast-deep-equal handles:
dequal({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }) // true
dequal([1, 2, [3, 4]], [1, 2, [3, 4]]) // true
dequal(new Date("2026"), new Date("2026")) // true
// Plus Maps:
dequal(
new Map([["a", 1], ["b", 2]]),
new Map([["a", 1], ["b", 2]])
) // true
// Plus Sets:
dequal(new Set([1, 2, 3]), new Set([1, 2, 3])) // true
// Order matters for Sets? No:
dequal(new Set([1, 2, 3]), new Set([3, 2, 1])) // true (sets are unordered)
// But Map key order doesn't matter:
dequal(
new Map([["a", 1], ["b", 2]]),
new Map([["b", 2], ["a", 1]])
) // true
Lite version (no Map/Set, smaller)
import { dequal } from "dequal/lite" // ~350B, no Map/Set support
// Same API as dequal, but slightly smaller when you don't need Map/Set:
dequal({ a: 1 }, { a: 1 }) // true
dequal(new Map([["a", 1]]), new Map([["a", 1]])) // false (lite doesn't support Map)
Lodash _.isEqual
Lodash isEqual — the most complete equality checker:
Usage
import isEqual from "lodash/isEqual" // Tree-shakable import
// Or:
import { isEqual } from "lodash-es" // ESM version
// Everything the others handle, plus:
// Circular references:
const a: any = { name: "react" }
a.self = a // Circular!
const b: any = { name: "react" }
b.self = b // Circular!
isEqual(a, b) // true ✅ (others throw RangeError: Maximum call stack size exceeded)
// Error objects:
isEqual(new Error("oops"), new Error("oops")) // true ✅
// Arguments objects:
function test(...args: any[]) {
return isEqual(args, [1, 2, 3])
}
test(1, 2, 3) // true ✅
// Inherited properties:
class Base { value = 1 }
class Child extends Base { extra = 2 }
const obj1 = new Child()
const obj2 = new Child()
isEqual(obj1, obj2) // true (compares own + inherited enumerable properties)
When _.isEqual is the only option
import isEqual from "lodash/isEqual"
// Comparing Redux state with circular references from immer drafts:
function hasStateChanged(prev: AppState, next: AppState): boolean {
return !isEqual(prev, next)
}
// Comparing complex objects with potential circular structures:
const cache = new Map<string, ComplexObject>()
function hasChanged(key: string, newValue: ComplexObject): boolean {
const cached = cache.get(key)
if (!cached) return true
return !isEqual(cached, newValue) // Safe even if objects have circular refs
}
Performance Comparison
Benchmark: 1M iterations, medium-sized nested object
Object.is (reference): ~5ms (fastest — but only reference equality)
fast-deep-equal: ~180ms (fastest deep)
dequal: ~220ms
dequal/lite: ~200ms
_.isEqual (lodash): ~450ms
For most use cases, the difference is negligible.
Use fast-deep-equal when you're comparing thousands of times per second
(e.g., in a rendering loop or hot code path).
Feature Comparison
| Feature | fast-deep-equal | dequal | lodash.isEqual |
|---|---|---|---|
| Bundle size | ~400B | ~620B | ~2KB |
| Objects/arrays | ✅ | ✅ | ✅ |
| Date | ✅ | ✅ | ✅ |
| RegExp | ✅ | ✅ | ✅ |
| TypedArrays | ✅ es6 import | ✅ | ✅ |
| Map | ❌ | ✅ | ✅ |
| Set | ❌ | ✅ | ✅ |
| Circular refs | ❌ (crash) | ❌ (crash) | ✅ |
| WeakMap/WeakSet | ❌ | ❌ | ❌ |
| Performance | ⚡ Fastest | Fast | Moderate |
| TypeScript | ✅ | ✅ | ✅ |
When to Use Each
Choose fast-deep-equal if:
- Performance is critical — high-frequency comparisons in rendering loops
- You don't need Map/Set equality (most React state doesn't use them)
React.memocustom comparator — most component props are plain objects- You can guarantee no circular references in the data you're comparing
Choose dequal if:
- Your data structures include Maps, Sets, or TypedArrays
- You want a slightly more complete alternative to fast-deep-equal
- Still need small bundle size (620B vs 2KB)
- No circular references in your data
Choose lodash.isEqual if:
- Circular references are possible (deeply nested Redux state, graph structures)
- You're already using Lodash elsewhere (no additional cost)
- Edge cases matter more than performance
- Comparing complex class instances with inherited properties
Avoid JSON.stringify comparison:
// DON'T DO THIS:
JSON.stringify(obj1) === JSON.stringify(obj2)
// Problems:
// - Key order sensitivity: { a: 1, b: 2 } !== { b: 2, a: 1 }
// - Loses type info: Date becomes string
// - Throws on circular references
// - Much slower than fast-deep-equal
Real-World Integration Patterns
Beyond benchmarks, the choice between these libraries often comes down to where they appear in your actual codebase. The most common integration point for fast-deep-equal is custom equality functions in React — both React.memo comparators and useEffect dependency stabilization. The pattern of wrapping a useRef to track "previous deep value" and only firing effects when the value genuinely changes is one of the highest-leverage uses of any deep equality library, because the default React behavior triggers effects on every render even when the serialized value hasn't changed. Fast-deep-equal handles this at ~400 bytes with no runtime overhead beyond the comparison itself.
Dequal earns its place in state management layers where your store legitimately contains Map or Set values. Zustand selectors, Jotai atoms, and Valtio snapshots occasionally surface these collection types, especially in applications that model unique sets of selected items or keyed lookup structures. When your selector returns new Set(state.selectedIds), shallow equality always fails — you need dequal or lodash.isEqual to prevent unnecessary re-renders. Dequal's 620B footprint makes it a reasonable default upgrade over fast-deep-equal for any project that touches these types.
Lodash _.isEqual belongs in the layer that handles third-party or legacy data — Redux state shaped by immer drafts that may contain circular prototype chains, configuration objects from JSON imports that carry class instances, or deep merge results from complex normalization pipelines. Its circular reference handling is not just a theoretical feature: immer draft objects in certain configurations create internal circular references that cause fast-deep-equal and dequal to throw a stack overflow. If you're diffing Immer state outside of the reducer itself, _.isEqual is the safe choice.
Migrating Between Libraries
Switching deep equality libraries is low-risk because they share the same function signature: (a: unknown, b: unknown) => boolean. The migration path is almost always a one-line import change. The practical exception is moving from lodash.isEqual to fast-deep-equal or dequal: you need to audit whether any compared values contain Maps, Sets, or circular references. A quick search for new Map(, new Set(, and .self = across your codebase catches the most common cases.
When migrating toward dequal's lite variant (dequal/lite), the tradeoff is an extra 270B savings over the full build in exchange for losing Map/Set support. This is worth it for bundles where every byte counts — for example, a library distributed to third parties or a component used in a micro-frontend. The API is identical, so it's a drop-in replacement for fast-deep-equal in pure-object codebases.
One area where no migration is appropriate: comparing values with WeakMap or WeakSet entries, or non-enumerable properties added via Object.defineProperty. None of the three libraries handle these correctly. If your data model uses these patterns — for example, hidden metadata attached to objects in a caching layer — you need custom equality logic or a serialization step before comparison.
Testing Deep Equality Logic
Unit testing code that depends on deep equality requires understanding how each library handles edge cases so your tests can cover the right scenarios. For fast-deep-equal, the critical tests are around Map and Set values — if your production code accidentally starts using new Map() for a data structure that previously used a plain object, fast-deep-equal will return false where your tests expect true, and the bug may not surface until a component stops memoizing correctly. Writing explicit tests that compare Maps, Sets, and Date objects in your equality layer catches these gaps before they reach production.
Dequal's test coverage should focus on the boundary between full and lite builds. If your project switches between dequal and dequal/lite due to bundle size constraints, tests that explicitly verify Map and Set equality will catch the regression immediately. A test file that imports from both builds and asserts their behavior difference is a useful safety net during dependency upgrades.
Performance Profiling in React Hot Paths
Deep equality checks in React render paths deserve careful profiling because they are often invoked far more frequently than developers expect. A custom areEqual comparator passed to React.memo is called on every parent render, not just when props change. If your component tree has 50 memoized list items and the parent re-renders 10 times per second (common in real-time dashboards), that is 500 areEqual invocations per second. At those frequencies, the 2.5x performance difference between fast-deep-equal and lodash.isEqual translates to measurable frame budget impact on low-powered mobile devices.
The React DevTools profiler is the right tool for identifying whether deep equality checking is a bottleneck in your rendering pipeline. The "Ranked" view shows components ordered by render time. If memoized components appear high in this list despite not changing, their equality check is either too slow or not working correctly (false positives). React's useMemo and useCallback hooks have similar profiling implications: a useMemo with a deep equality comparator as a dependency guard runs its comparison on every render, so the comparison cost is part of the hook's total overhead.
For applications where deep equality genuinely becomes a hot-path bottleneck, structural sharing offers a more efficient alternative. Immutable data structures like those produced by Immer or Immutable.js guarantee that unchanged data keeps the same reference — so a shallow reference equality check (Object.is(prev, next)) serves the same purpose as a deep comparison, at a fraction of the cost. The architectural choice between deep equality (correct for mutable objects) and reference equality (correct for immutable data) is often more impactful than choosing between fast-deep-equal and dequal.
Methodology
Download data from npm registry (weekly average, February 2026). Performance benchmarks are approximate. Feature comparison based on fast-deep-equal v3.x, dequal v2.x, and lodash v4.x.
Compare utility and JavaScript packages on PkgPulse →
See also: React vs Vue and React vs Svelte, culori vs chroma-js vs tinycolor2.