- Node.js
- Alerts
- Analytics
JSON diff in Node.js: compare two JSON objects and turn the changes into alerts
JSON diff in Node.js, step by step: test equality with isDeepStrictEqual, list changed paths, pick a library, handle arrays and noise, and alert on changes.
- Author
- Extenify Team
- Published
- Reading time
- 14 min read

On this page
A JSON diff in Node.js sounds like a one-liner until you try it on real data. JSON.stringify(a) === JSON.stringify(b) says two identical objects differ because their keys came back in a different order. A generic deep diff reports twenty changes when one item was added to the top of a list. And a store's rating that moved from 4.6012 to 4.6049 fires an alert nobody cares about. Stack Overflow's Generic deep diff between two objects has collected close to 400,000 views on exactly this problem.
This guide builds a JSON diff that works for monitoring: storing a daily snapshot of something (a store listing, an API response, a config file), comparing it with yesterday's, and turning the differences into a few readable alert lines. It covers the equality check built into Node.js, a dependency-free diff function, the libraries worth using and how their output differs, the array problem, normalization, and a complete daily job. All code was run on Node.js 22 on October 12, 2026.
Key takeaways
- To ask "did anything change?", use
util.isDeepStrictEqualfromnode:util. Do not compareJSON.stringifyoutput, which depends on key order.- To ask "what changed?", walk both objects and emit one record per changed path: added, removed or changed, with the old and new value.
- Arrays are the hard part. Diffing by position turns one insertion into a cascade of changes. Key records by id, and treat unordered lists as sets.
- Normalize before you diff: drop fields that change on every fetch and round numbers to the precision people read.
- A diff is not an alert. Map paths to rules, and drop every change no rule claims.
Did anything change? Use util.isDeepStrictEqual
The cheapest question comes first. If nothing changed, you can skip the diff entirely.
import { isDeepStrictEqual } from 'node:util';
const a = { name: 'Dark Reader', rating: 4.48, version: '4.9.133' };
const b = { version: '4.9.133', name: 'Dark Reader', rating: 4.48 };
JSON.stringify(a) === JSON.stringify(b); // false: same data, different key order
isDeepStrictEqual(a, b); // true
isDeepStrictEqual({ x: 1 }, { x: '1' }); // false: strict, so 1 and '1' differ
The Node.js documentation says the function "Returns true if there is deep strict equality between val1 and val2", and it has been available since Node.js 9. It compares nested objects and arrays, ignores key order and uses strict equality for values. For parsed JSON that is exactly what you want.
What changed? A JSON diff in Node.js in 25 lines
Monitoring needs more than a yes or no. You need each change as data: the path, what kind of change it was, and the values on both sides. For parsed JSON (objects, arrays, strings, numbers, booleans and null, with no dates or cycles) a recursive walk is enough:
// diff-json.mjs: list every changed path between two parsed JSON values.
export function diffJson(before, after, path = []) {
if (Object.is(before, after)) return [];
const bothObjects =
before !== null && after !== null &&
typeof before === 'object' && typeof after === 'object' &&
Array.isArray(before) === Array.isArray(after);
if (!bothObjects) {
return [{ op: 'change', path, before, after }];
}
const changes = [];
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
for (const key of keys) {
const next = [...path, Array.isArray(before) ? Number(key) : key];
if (!Object.hasOwn(after, key)) {
changes.push({ op: 'remove', path: next, before: before[key] });
} else if (!Object.hasOwn(before, key)) {
changes.push({ op: 'add', path: next, after: after[key] });
} else {
changes.push(...diffJson(before[key], after[key], next));
}
}
return changes;
}
Run it on two snapshots of an extension listing:
import { diffJson } from './diff-json.mjs';
const yesterday = {
version: '2.3.0',
users: 18400,
rating: { average: 4.6, count: 212 },
permissions: ['storage', 'activeTab'],
};
const today = {
version: '2.4.0',
users: 18950,
rating: { average: 4.5, count: 219 },
permissions: ['storage', 'activeTab', 'tabs'],
price: 'free',
};
console.log(diffJson(yesterday, today));
// [
// { op: 'change', path: ['version'], before: '2.3.0', after: '2.4.0' },
// { op: 'change', path: ['users'], before: 18400, after: 18950 },
// { op: 'change', path: ['rating', 'average'], before: 4.6, after: 4.5 },
// { op: 'change', path: ['rating', 'count'], before: 212, after: 219 },
// { op: 'add', path: ['permissions', 2], after: 'tabs' },
// { op: 'add', path: ['price'], after: 'free' },
// ]
When a value changes type (a number becomes null, an object becomes an array), the function reports one change at that path instead of descending into it, which is the useful answer for an alert. If your data can contain Date objects, class instances or cycles, use a library instead.
JSON diff libraries for Node.js compared
Four packages come up in almost every discussion. Their output formats differ more than their algorithms, so pick by what you will do with the result:
| Package | Latest version (Oct 2026) | Downloads, week of Oct 6 | Output | Best for |
|---|---|---|---|---|
| microdiff | 1.6.0, August 2026 | about 3.1 million | List of CREATE, REMOVE, CHANGE records with path, value, oldValue |
Alerts and change logs; zero dependencies |
| jsondiffpatch | 0.7.6, May 2026 | about 3.3 million | Compact delta object, plus HTML, console and JSON Patch formatters | Smart array diffs, patch and unpatch, visual diffs |
| fast-json-patch | 3.1.1, March 2022 | about 8.5 million | RFC 6902 JSON Patch operations | Sending or storing patches in a standard format |
| deep-diff | 1.0.2, August 2018 | about 3.7 million | List of changes with kind, path, lhs, rhs |
Existing code only; no release since 2018 |
Version dates come from the npm registry and download counts from npm's public downloads API for October 6 to 12, 2026.
Here is what each one returns for the same yesterday and today objects (without users):
import diff from 'microdiff';
import * as jsondiffpatch from 'jsondiffpatch';
import jsonPatch from 'fast-json-patch';
diff(yesterday, today);
// [{ path: ['version'], type: 'CHANGE', value: '2.4.0', oldValue: '2.3.0' },
// { path: ['rating', 'average'], type: 'CHANGE', value: 4.5, oldValue: 4.6 },
// { path: ['rating', 'count'], type: 'CHANGE', value: 219, oldValue: 212 },
// { type: 'CREATE', path: ['permissions', 2], value: 'tabs' },
// { type: 'CREATE', path: ['price'], value: 'free' }]
jsondiffpatch.diff(yesterday, today);
// { version: ['2.3.0', '2.4.0'],
// rating: { average: [4.6, 4.5], count: [212, 219] },
// permissions: { 2: ['tabs'], _t: 'a' },
// price: ['free'] }
jsonPatch.compare(yesterday, today);
// [{ op: 'add', path: '/permissions/2', value: 'tabs' },
// { op: 'replace', path: '/rating/count', value: 219 },
// { op: 'replace', path: '/rating/average', value: 4.5 },
// { op: 'replace', path: '/version', value: '2.4.0' },
// { op: 'add', path: '/price', value: 'free' }]
A few practical differences:
- microdiff keeps the old value on every change, which an alert needs ("rating 4.6 to 4.5"). Its README describes it as "a tiny (<1kb), fast, zero dependency object and array comparison library", and it handles
DateandRegExpvalues and cycles. For parsed JSON with no cycles,diff(a, b, { cyclesFix: false })skips the cycle bookkeeping. - jsondiffpatch is ESM-only and supports Node.js 18 and 20 or later, according to its README. Its deltas are compact and reversible, so you can patch an old object forward or unpatch a new one back.
- fast-json-patch emits RFC 6902 operations (
add,remove,replace,move,copy,test), which any JSON Patch implementation can apply.replacedoes not carry the old value, so you cannot write "was X, now Y" from the patch alone. Node.js loads it as CommonJS, soimport { compare } from 'fast-json-patch'fails with "Named export 'compare' not found"; import the default export as shown. - deep-diff still has millions of weekly downloads, mostly through older dependencies. Do not start new code on a package with no release in eight years.
The array problem: position versus identity
Every diff above treats an array as a list of positions. That works when items are appended. It breaks the moment something is inserted at the top, which is how most "latest first" lists work, such as reviews, releases or search results:
const before = [{ id: 'r1', stars: 5 }, { id: 'r2', stars: 4 }];
const after = [{ id: 'r0', stars: 1 }, { id: 'r1', stars: 5 }, { id: 'r2', stars: 4 }];
diff(before, after);
// [{ path: [0, 'id'], type: 'CHANGE', value: 'r0', oldValue: 'r1' },
// { path: [0, 'stars'], type: 'CHANGE', value: 1, oldValue: 5 },
// { path: [1, 'id'], type: 'CHANGE', value: 'r1', oldValue: 'r2' },
// { path: [1, 'stars'], type: 'CHANGE', value: 5, oldValue: 4 },
// { type: 'CREATE', path: [2], value: { id: 'r2', stars: 4 } }]
One new one-star review became five changes, none of which says "new one-star review". There are two fixes:
- Tell the library how to identify items. jsondiffpatch takes an
objectHashfunction. Its README warns that "to match objects inside an array you must provide anobjectHashfunction", otherwise "a dumb match by position is used". Withjsondiffpatch.create({ objectHash: (o) => o.id }), the same input produces a single insertion at index 0. - Turn the array into an object keyed by id before diffing. Then any diff, including the 25-line one above, reports
addat['reviews', 'r0']. This is the simplest option and works with every library.
Lists of plain strings, such as permissions or tags, have the same problem in a different form: their order carries no meaning, so a reordered list is not a change. Treat them as sets: { storage: true, tabs: true }. An added permission then shows up as add at ['permissions', 'tabs'], regardless of where it appeared in the array.
Normalize before you diff
Most noise in a diff comes from the data, not the algorithm. Clean both snapshots the same way before comparing them:
// normalize.mjs: make two snapshots comparable before diffing them.
const IGNORE = new Set(['fetchedAt', 'etag', 'requestId']); // change on every fetch
const SETS = new Set(['permissions', 'hostPermissions', 'tags']); // order means nothing
const KEYED = { reviews: 'id' }; // arrays of records, matched by this field
export function normalize(value, key) {
if (Array.isArray(value)) {
if (SETS.has(key)) {
return Object.fromEntries(value.map((item) => [String(item), true]));
}
if (Object.hasOwn(KEYED, key)) {
const idField = KEYED[key];
return Object.fromEntries(value.map((item) => [String(item[idField]), normalize(item)]));
}
return value.map((item) => normalize(item));
}
if (value !== null && typeof value === 'object') {
const out = {};
for (const [childKey, child] of Object.entries(value)) {
if (!IGNORE.has(childKey)) out[childKey] = normalize(child, childKey);
}
return out;
}
// Averages arrive as 4.6012 one day and 4.6049 the next: round to what people read.
if (typeof value === 'number' && !Number.isInteger(value)) return Math.round(value * 100) / 100;
return value;
}
The rules are named by field rather than guessed from the data, because an empty array gives you nothing to inspect: you cannot tell from [] whether it is a list of permissions or a list of reviews. Other normalizations worth considering for your own data: trimming whitespace in text fields, lowercasing hostnames and URLs, and converting date strings to one format.
Normalization also covers a quieter failure. If yesterday's fetch failed and you stored an empty list, today's full list looks like "everything is new". Store a flag that says whether each part of the snapshot was fetched successfully, and skip the comparison for parts that were not.
From a diff to an alert
A diff tells you everything that changed. An alert should tell you only what someone needs to act on. Map paths to rules, let each rule decide whether a change is worth a line, and drop everything else:
// describe.mjs: turn raw changes into alert lines. Unmatched changes are dropped.
const RULES = [
{
path: ['version'],
line: (c) => `New version ${c.after} (was ${c.before})`,
},
{
path: ['permissions', '*'],
line: (c) => (c.op === 'add' ? `New permission requested: ${c.path[1]}` : `Permission removed: ${c.path[1]}`),
},
{
path: ['reviews', '*'],
line: (c) => (c.op === 'add' ? `New ${c.after.stars}-star review` : null),
},
{
path: ['rating', 'average'],
line: (c) => (Math.abs(c.after - c.before) >= 0.05 ? `Rating ${c.before} to ${c.after}` : null),
},
];
const matches = (pattern, path) =>
pattern.length === path.length && pattern.every((part, i) => part === '*' || part === path[i]);
export function describe(changes) {
return changes
.map((change) => RULES.find((rule) => matches(rule.path, change.path))?.line(change) ?? null)
.filter(Boolean);
}
Thresholds belong in these rules, not in the diff. A rating that moved by 0.01 is a real change and a useless alert; a new permission is always worth a line, because Chrome disables an extension until users accept a new permission warning. Our guide to setting up store change alerts your team will actually read covers which changes deserve a same-day response.
A complete daily snapshot job
Put the pieces together and you have a small listing monitor. This example uses Mozilla's public add-on API, which needs no key, so you can run it as is:
// watch-listing.mjs: snapshot a Firefox add-on once a day and print what changed.
// Usage: node watch-listing.mjs <addon-slug>
import { readFile, writeFile } from 'node:fs/promises';
import { normalize } from './normalize.mjs';
import { diffJson } from './diff-json.mjs';
import { describe } from './describe.mjs';
const slug = process.argv[2];
const file = `./snapshot-${slug}.json`;
const res = await fetch(`https://addons.mozilla.org/api/v5/addons/addon/${slug}/`);
if (!res.ok) throw new Error(`AMO returned ${res.status}`);
const addon = await res.json();
// Keep only the fields you alert on: a smaller snapshot means a quieter diff.
const today = {
fetchedAt: new Date().toISOString(),
version: addon.current_version.version,
users: addon.average_daily_users,
rating: { average: addon.ratings.average, count: addon.ratings.count },
permissions: addon.current_version.file.permissions,
hostPermissions: addon.current_version.file.host_permissions,
};
const yesterday = await readFile(file, 'utf8').then(JSON.parse, () => null);
if (yesterday) {
const lines = describe(diffJson(normalize(yesterday), normalize(today)));
console.log(lines.length ? lines.join('\n') : 'No changes worth an alert.');
}
await writeFile(file, JSON.stringify(today, null, 2));
On the second run after an update, the output looks like this:
New version 4.9.133 (was 4.9.120)
Rating 4.55 to 4.48
New permission requested: tabs
To make it production-ready:
- Store snapshots in a database, not files. Keep one document per listing per day, so you can diff any two dates and chart the numbers. Our guide to MongoDB time series collections for extension stats has a schema for exactly this.
- Store the diff too. Saving the raw changes next to each snapshot gives you a change log for free. JSON Patch from fast-json-patch is a good storage format, since it is a standard and can be replayed.
- Send the lines somewhere people read. Our guides to Discord webhooks in Node.js and Telegram bot notifications show the delivery side, with retries and rate limits.
- Respect the source. Fetch once a day per listing, send a descriptive User-Agent, and back off on errors. Mozilla's ratings and add-on API docs note that the v5 endpoints are not frozen, so validate the fields you read.
The same pattern underlies version tracking. Our guide to Chrome extension version history shows what a version diff can tell you about a release, yours or a competitor's.
Skip the plumbing for store listings
If what you want to diff is store listings, Extenify already runs this pipeline. It is an extension version tracker and competitor extension monitoring dashboard that snapshots your extensions on the Chrome Web Store, Edge Add-ons and Firefox Add-ons, and your apps on Google Play and the App Store, every day, compares each snapshot with the previous one, and sends one digest per product with the changes that matter: new versions, permission and manifest changes, rating moves, new reviews and bursts of reviews, growth that speeds up or slows down, and edits to the listing itself. Digests go to email, Slack, Discord, Telegram or Microsoft Teams. To see what those growth lines are based on, read our guide to Chrome extension user growth, and for reviews specifically, our guide to Chrome extension review notifications. Every account starts with a 7-day free trial, and the pricing page lists the plans.
Frequently asked questions
How do I compare two JSON objects in Node.js?
For a yes or no answer, use util.isDeepStrictEqual(a, b) from node:util. It ignores key order and compares nested values strictly. To find out what changed, walk both objects and record each added, removed or changed path, or use a library such as microdiff.
Why does JSON.stringify comparison fail for equal objects?
JSON.stringify writes keys in insertion order, so two objects with the same keys and values in a different order produce different strings. APIs often return keys in varying order, so string comparison reports false changes.
What is the best JSON diff library for Node.js?
It depends on the output you need. microdiff is tiny, has zero dependencies and returns old and new values, which suits alerts. jsondiffpatch handles arrays intelligently with an objectHash function and can patch and unpatch. fast-json-patch produces standard RFC 6902 JSON Patch operations. Avoid deep-diff for new code, since it has not been released since 2018.
How do I diff arrays of objects by id?
Either pass an id function to a library that supports it, such as jsondiffpatch's objectHash, or convert the array to an object keyed by id before diffing. Both make an inserted item show up as one addition instead of a cascade of changes at shifted positions.
What is the difference between JSON Patch and a JSON diff?
A JSON diff is any description of the differences between two documents. JSON Patch, defined in RFC 6902, is one standard format for it: a list of operations such as add, remove and replace, addressed with JSON Pointer paths, that can be applied to the first document to produce the second.
Summary
A JSON diff in Node.js for monitoring has four steps. Check equality with util.isDeepStrictEqual and stop if nothing changed. Normalize both snapshots: drop volatile fields, round numbers, key record arrays by id and treat unordered lists as sets. Diff them with a short recursive function or a library chosen by its output format. Then map the changed paths to rules that decide what deserves an alert, and drop the rest. Run that once a day against a stored snapshot and you have a change monitor that reports a new version, a new permission or a new one-star review in one readable line each.
Image credits
- Cover photo: Two modern buildings by Mary Mojisola Job on the WordPress Photo Directory, CC0.


