jashkenas/underscore · error · RangeError
Comparison limit exceeded. Wrap call to isEqual in try/catch
Error message
Comparison limit exceeded. Wrap call to isEqual in try/catch or limit the depth of compared objects.
What it means
_.isEqual keeps a global count of comparisons during a single call; when recursive/cyclic data pushes that count past the internal comparisonLimit, the cycleTracker aborts the operation with a RangeError rather than degrading performance quadratically. It means your inputs are too deep, too large, or contain cycles — not that the values differ.
Source
Thrown at modules/isEqual.js:86
return {
tracked: [],
trackedB: [],
lookups: 0,
push: function(a, b) {
this.tracked.push(a);
this.trackedB.push(b);
},
pop: function() {
this.tracked.pop();
this.trackedB.pop();
},
has: function(a) {
// While the algorithm could run to arbitrary comparison depth in
// principle, the quadratic runtime cost is going to hurt performance
// significantly once the depth reaches over a few thousand levels. To
// prevent excessive performance degradation, we keep track of the number
// of comparisons and abort the operation when this number passes a limit.
if (this.lookups >= comparisonLimit) throw RangeError(
'Comparison limit exceeded. Wrap call to isEqual in try/catch or ' +
'limit the depth of compared objects.'
);
// The following loop is **the** hot loop, so we keep it as light as
// possible.
for (var i = 0, l = this.tracked.length; i < l; ++i) {
if (this.tracked[i] === a) break;
}
// We only update the number of comparisons after the loop, exploiting the
// fact that `i` is still in scope and contains the approximate number of
// comparisons made. `this.lookups` can exceed `comparisonLimit` as a
// result, but this is unproblematic.
this.lookups += i;
// Return the index where `a` was found, plus one so it doesn't look
// falsy.
return i < l ? i + 1 : false;
},
// The `match` method takes one argument more than the corresponding methodView on GitHub (pinned to e70d5bd070)
Solutions
- Break cycles before comparing: strip or replace circular references (e.g. with a cycle-removal helper) and then call _.isEqual.
- Compare a shallow or bounded-depth projection instead: pick specific keys or cap the nesting level you care about.
- Use a diff/deep-equal library that supports cyclic data natively (e.g. one tracking visited nodes with WeakMap/WeakSet) for graph-shaped data.
- Compare canonical serializations (JSON.stringify with a cycle-safe replacer) when semantic identity is what matters.
- If the size is the problem, chunk the comparison: compare top-level keys/sections with separate _.isEqual calls.
Example fix
// before
_.isEqual(circularA, circularB);
// RangeError: Comparison limit exceeded...
// after
const strip = (v, seen = new WeakSet()) => {
if (v && typeof v === 'object') {
if (seen.has(v)) return '[circular]';
seen.add(v);
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, strip(x, seen)]));
}
return v;
};
_.isEqual(strip(circularA), strip(circularB)); Defensive patterns
Strategy: try-catch
Validate before calling
function detectCycle(v, seen = new WeakSet()) {
if (v && typeof v === 'object') {
if (seen.has(v)) return true;
seen.add(v);
return Object.values(v).some((x) => detectCycle(x, seen));
}
return false;
}
// if (detectCycle(a) || detectCycle(b)) use a cycle-safe comparison instead Type guard
const isCycleSafe = (v, seen = new WeakSet()) => {
if (v === null || typeof v !== 'object') return true;
if (seen.has(v)) return false;
seen.add(v);
return Object.values(v).every((x) => isCycleSafe(x, seen));
}; Try / catch
try {
return _.isEqual(a, b);
} catch (e) {
if (e instanceof RangeError && /Comparison limit exceeded/.test(e.message)) {
return deepEqualCycleSafe(a, b); // fallback comparator using WeakSet of visited nodes
}
throw e;
} Prevention
- Keep object graphs acyclic (no parent back-references) or strip cycles before deep comparisons.
- Avoid calling _.isEqual on very deep structures; compare projections or selected keys instead.
- Use a cycle-aware deep-equal implementation for entity/ORM graphs.
- For large configs, diff section-by-section with separate isEqual calls to stay under the limit.
- Wrap isEqual calls over untrusted/dynamic data in try/catch so a RangeError degrades gracefully.
When it happens
Trigger: Calling _.isEqual(a, b) where a/b are (a) circularly referenced objects or arrays, (b) extremely deeply nested structures (thousands of levels), or (c) very large sibling-heavy structures whose pairwise comparisons accumulate past the limit.
Common situations: Comparing parsed JSON that unexpectedly contains back-references (e.g. linked parent/child nodes or DOM-like trees); diffing Redux/ORM entity graphs with cycles; comparing two huge auto-generated config trees; deep-cloned structures that preserved circular links.
AI-assisted analysis of jashkenas/underscore@e70d5bd070 (2026-08-29).
Data as JSON: /api/errors/432d744531d185d8.
Report an issue: GitHub.