A Retry Bug Charged Customers Twice Because of One Line of JavaScript. What It Actually Takes to Compare Two JSON Objects
A duplicate-charge incident traced to JSON.stringify equality, and the deep-equality approach that actually compares two JSON objects in JavaScript.

A subscription billing service kept a Redis key for every webhook event it processed, so a retried delivery wouldn't be charged twice. The key was built by running the event payload through JSON.stringify(). Before processing anything, the handler checked whether that exact string already existed in Redis. If it did, the event was a duplicate and got dropped. If not, it processed the charge and stored the key.
It worked for about a year. Then a payment provider started retrying webhooks more aggressively after a string of timeouts on their end, and a small number of customers started seeing two identical charges on the same invoice, minutes apart.
The team's first assumption was a race condition, two requests hitting the handler at the same instant before either had written its dedup key. That wasn't it, the timestamps were seconds apart, plenty of time for a Redis write to land. The second assumption was a bug in the provider's retry logic sending genuinely different event IDs. Also not it, the event IDs matched.
What had actually changed was upstream of the handler. A recent refactor swapped Object.assign({}, base, overrides) for object spread in the code path that reconstructed the payload before re-delivery. Both produce an object with the same keys and the same values. They don't produce it with the same key order. And JSON.stringify is order-sensitive: it writes keys in the order they exist on the object, not sorted, not normalized. Two objects that are unmistakably "the same JSON" by any reasonable definition can stringify to two different strings, and a dedup check built on string equality will wave the second one straight through.
Nobody had a bug in their business logic. They had a comparison that was never actually comparing what they thought it was.
The fastest way to catch this kind of thing is visual, not written. Pretty-print the two raw payloads with a JSON Formatter so the key order is actually readable, then run them through JSON Diff to see whether they differ in shape at all or only in serialization order. That's a five-second manual check that would have caught this specific incident before it ever reached a postmortem.
JSON.stringify(a) === JSON.stringify(b) Is a Coincidence, Not a Guarantee
Try this in a console:
const a = { id: 42, status: 'paid' }
const b = { status: 'paid', id: 42 }
a === b // false, different object references, expected
JSON.stringify(a) === JSON.stringify(b) // falseThat second false catches people. a and b describe the same record. Any human reading them would call them equal. But JSON.stringify walks an object's keys in the order the engine reports them, and for string keys that order is insertion order. { id: 42, status: 'paid' } and { status: 'paid', id: 42 } insert their keys in a different sequence, so they serialize differently, so the strings compare unequal.
It gets stranger with numeric-looking keys. JavaScript objects sort integer-like string keys ascending and put them before all other keys, regardless of where you wrote them in the source:
JSON.stringify({ b: 1, 2: 'x', a: 3 })
// '{"2":"x","b":1,"a":3}' ← the "2" key jumps to the frontNone of this is a bug in JSON.stringify. It's doing exactly what a serializer should do: produce a deterministic string for one specific object, in one specific key order. The bug is upstream, in treating "these two strings match" as a proxy for "these two objects represent the same data." Those are different claims, and they only agree by coincidence, when both objects happen to have been built through the same code path in the same order.
What "Equal" Actually Means for Two JSON Values
Once you stop reaching for stringify, you have to decide what equality means for each JSON type, because it isn't the same rule everywhere.
Objects are unordered. {"a":1,"b":2} and {"b":2,"a":1} are the same value. Key order is an artifact of how the object was constructed, not part of the data.
Arrays are ordered. [1, 2, 3] and [3, 2, 1] are not the same value. Position carries meaning, so array comparison has to walk both arrays index by index, not just check that they contain the same elements.
Primitives compare by value, with two edge cases that trip people up: NaN !== NaN under ===, even though for the purposes of "are these two numbers the same number" you almost always want them to count as equal. And -0 === 0 is true under ===, but Object.is(-0, 0) is false, which occasionally matters if a computed value can land on negative zero.
Missing keys and undefined values are not the same thing to JSON.stringify, even though they often are to your code. JSON.stringify({ a: undefined }) produces "{}", the key vanishes entirely. So { a: undefined } and {} stringify identically, but a deep-equality check that walks live JavaScript objects (rather than round-tripping through JSON first) has to decide deliberately whether a missing key and a key set to undefined count as equal. There's no universally correct answer, it depends on whether undefined means "absent" or "explicitly cleared" in your data model.
Writing an Actual Deep-Equality Check
A correct comparison has to recurse, and it has to apply the right rule at each level: unordered for objects, ordered for arrays, value equality for primitives.
function deepEqual(a, b) {
if (Object.is(a, b)) return true
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
return false
}
if (Array.isArray(a) !== Array.isArray(b)) return false
if (Array.isArray(a)) {
if (a.length !== b.length) return false
return a.every((val, i) => deepEqual(val, b[i]))
}
const keysA = Object.keys(a)
const keysB = Object.keys(b)
if (keysA.length !== keysB.length) return false
return keysA.every((key) =>
Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key])
)
}Object.is at the top handles primitives correctly, including the NaN and -0 cases that === gets wrong. Objects are compared by checking both sides have the same set of keys, then recursing into each value, with no dependency on the order those keys happen to be stored in. Arrays recurse by index, so [1, 2] and [2, 1] correctly come back unequal.
This is roughly what lodash.isEqual and the fast-deep-equal package do under the hood, and it's what Node's built-in util.isDeepStrictEqual gives you for free if you're in a Node environment and don't want to hand-roll it. Any of these are the right tool when the question is "do these two JSON values represent the same data," which is a boolean question with one correct answer regardless of how either object happened to be constructed. If it's a one-off check rather than something that needs to run in application code, skip the REPL entirely and paste both objects into JSON Diff instead.
The Question That Actually Needed Answering
Here's the part the billing team got wrong before they ever got to the equality question: a boolean "are these equal" wasn't what their idempotency check needed in the first place. What they needed was a stable identifier, something that produces the same value for the same logical event no matter which code path built the in-memory object. JSON.stringify on the raw payload can't do that, because it's sensitive to exactly the kind of incidental variation, key order, whitespace, that a stable identifier is supposed to ignore.
If you genuinely need a deterministic string, sort the keys before you serialize:
function stableStringify(value) {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`
}
if (value !== null && typeof value === 'object') {
const keys = Object.keys(value).sort()
const entries = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
return `{${entries.join(',')}}`
}
return JSON.stringify(value)
}
stableStringify({ id: 42, status: 'paid' }) === stableStringify({ status: 'paid', id: 42 })
// trueThat fixes the key-order problem specifically. It's still the wrong foundation for an idempotency key, though, and this is the fix the team actually shipped: don't derive a dedup key from the entire payload at all. Derive it from the one field that's guaranteed stable across every retry, the event ID the payment provider assigns. The payload can be rebuilt a dozen different ways by a dozen different code paths and none of it matters, because the key never touches the payload shape in the first place.
The stringify bug was real and worth fixing on its own. But it was a symptom of using the wrong primitive for the job: reaching for string equality on serialized data when what the system actually needed was either a proper recursive equality check, or better still, an identifier that didn't depend on object shape at all.