Comparing two JSON documents by eye is slow and makes subtle changes easy to miss. A useful JSON diff tool parses both inputs, compares their values recursively, and highlights additions, removals, and changes without treating object key order as significant.
In this project tutorial, you’ll build a browser-based comparison function, test nested objects and arrays, and render the results safely in the page. The example compares JSON data—not raw text—so formatting and object property order won’t create false differences.
Key takeaways
- Parse both inputs with
JSON.parseand show clear errors when either document is invalid. - Compare objects by their keys and values, not by the order in which keys appear.
- Treat array order as meaningful and compare elements by index.
- Represent missing properties separately from properties whose value is
null. - Render values with DOM text APIs rather than injecting untrusted JSON as HTML.
Decide what counts as a difference
A JSON diff tool needs clear comparison rules before it needs colors or layout. This tutorial uses the following rules:
| JSON value | Comparison rule |
|---|---|
| Object | Compare the union of its keys; key order does not matter. |
| Array | Compare elements at matching indexes; order and length matter. |
String, number, boolean, or null | Report a change when the values differ. |
| Missing object property | Report an addition or removal, distinct from a value of null. |
These rules are appropriate for many API responses and configuration files. They do not attempt to infer that array elements moved or that two different values are semantically equivalent. For example, ["a", "b"] and ["b", "a"] differ because their elements occupy different indexes.
Parse the two JSON inputs
A minimal page can provide two text areas and a results container:
<label for="left-json">Original JSON</label>
<textarea id="left-json" spellcheck="false">{"name":"Ada","active":true}</textarea>
<label for="right-json">Updated JSON</label>
<textarea id="right-json" spellcheck="false">{"name":"Ada","active":false}</textarea>
<button id="compare-button" type="button">Compare</button>
<p id="error" role="alert"></p>
<div id="results" aria-live="polite"></div>
Parse each input inside a try/catch. Parsing checks syntax, but it does not compare values or validate an application-specific schema. A valid JSON document can still contain the wrong fields or values for your API.
function parseInput(text, label) {
try {
return { value: JSON.parse(text), error: null };
} catch (error) {
return {
value: null,
error: `${label}: ${error.message}`
};
}
}
The result uses an explicit error field so that valid JSON null is not confused with a parse failure.
Build a recursive structural comparison
The comparison function below produces one record per difference. Each record has a JSON Pointer-style path and a kind: added, removed, or changed. A root-level difference uses an empty path. Pointer tokens escape ~ as ~0 and / as ~1, so property names containing those characters remain unambiguous.
function toPointer(path) {
return path
.map((part) => String(part).replace(/~/g, "~0").replace(/\//g, "~1"))
.map((part) => `/${part}`)
.join("");
}
function compareJson(before, after) {
const differences = [];
function visit(left, right, path) {
const leftIsArray = Array.isArray(left);
const rightIsArray = Array.isArray(right);
if (leftIsArray || rightIsArray) {
if (!leftIsArray || !rightIsArray) {
differences.push({
path: toPointer(path),
kind: "changed",
before: left,
after: right
});
return;
}
const sharedLength = Math.min(left.length, right.length);
for (let index = 0; index < sharedLength; index += 1) {
visit(left[index], right[index], [...path, index]);
}
for (let index = sharedLength; index < left.length; index += 1) {
differences.push({
path: toPointer([...path, index]),
kind: "removed",
before: left[index]
});
}
for (let index = sharedLength; index < right.length; index += 1) {
differences.push({
path: toPointer([...path, index]),
kind: "added",
after: right[index]
});
}
return;
}
const leftIsObject = left !== null && typeof left === "object";
const rightIsObject = right !== null && typeof right === "object";
if (leftIsObject || rightIsObject) {
if (!leftIsObject || !rightIsObject) {
differences.push({
path: toPointer(path),
kind: "changed",
before: left,
after: right
});
return;
}
const keys = [...new Set([...Object.keys(left), ...Object.keys(right)])]
.sort();
for (const key of keys) {
const leftHasKey = Object.hasOwn(left, key);
const rightHasKey = Object.hasOwn(right, key);
const childPath = [...path, key];
if (!leftHasKey) {
differences.push({
path: toPointer(childPath),
kind: "added",
after: right[key]
});
} else if (!rightHasKey) {
differences.push({
path: toPointer(childPath),
kind: "removed",
before: left[key]
});
} else {
visit(left[key], right[key], childPath);
}
}
return;
}
if (left !== right) {
differences.push({
path: toPointer(path),
kind: "changed",
before: left,
after: right
});
}
}
visit(before, after, []);
return differences;
}
Object.hasOwn checks whether a property exists directly on the parsed object. That matters because a property with the value null is present, while a missing property is not. The function also treats an array and an object as different types, even though JavaScript reports typeof [] as "object".
Parsed JSON cannot contain cycles, functions, or undefined, so this recursion is suitable for values produced by JSON.parse. If you reuse it with arbitrary JavaScript objects, add rules for those additional types and consider cycle detection.
Compare arrays and nested objects
Try this pair of inputs:
{
"user": { "name": "Ada", "role": "editor" },
"tags": ["api", "docs"],
"enabled": true
}
{
"enabled": true,
"user": { "name": "Ada", "role": "admin", "team": null },
"tags": ["api", "reference"]
}
The comparison reports a changed value at /user/role, an added property at /user/team, and a changed array element at /tags/1. It reports no difference for the reordered top-level keys. The added team property is distinct from a property that was already present with the value null.
Array entries are compared by index. If an item is inserted near the beginning, later items may appear changed because their indexes shifted. Detecting moves or matching array elements by an identifier is a separate diff strategy and depends on the meaning of the data.
Test the comparison rules
Small tests catch common bugs, especially confusing missing values with null or accidentally comparing serialized object strings. These examples use Node.js’s built-in assertion module; the same expectations can be adapted to a browser test runner.
import assert from "node:assert/strict";
assert.deepEqual(
compareJson(JSON.parse('{"a":1,"b":2}'), JSON.parse('{"b":2,"a":1}')),
[]
);
assert.deepEqual(
compareJson(JSON.parse('{"value":null}'), JSON.parse('{}')),
[{ path: "/value", kind: "removed", before: null }]
);
assert.deepEqual(
compareJson(JSON.parse('{"items":[1,2]}'), JSON.parse('{"items":[1,3,4]}')),
[
{ path: "/items/1", kind: "changed", before: 2, after: 3 },
{ path: "/items/2", kind: "added", after: 4 }
]
);
assert.deepEqual(
compareJson(JSON.parse('{"a/b":1}'), JSON.parse('{"a/b":2}')),
[{ path: "/a~1b", kind: "changed", before: 1, after: 2 }]
);
Also test invalid JSON, root-level value changes, an empty object versus a non-empty object, nested arrays, and arrays of different lengths. Tests should check both the kind and path of each difference, not just the number of results.
Highlight differences safely in the browser
Use text nodes or textContent to display paths and values. Do not build HTML by concatenating JSON-derived strings into innerHTML: JSON input is untrusted, and a renderer should not interpret it as markup.
function formatValue(value) {
return JSON.stringify(value, null, 2);
}
function renderDifferences(container, differences) {
container.replaceChildren();
if (differences.length === 0) {
const message = document.createElement("p");
message.textContent = "No differences found.";
container.append(message);
return;
}
const list = document.createElement("ul");
for (const difference of differences) {
const item = document.createElement("li");
item.className = `diff-${difference.kind}`;
const heading = document.createElement("strong");
heading.textContent = `${difference.kind}: ${difference.path || "(root)"}`;
item.append(heading);
if (Object.hasOwn(difference, "before")) {
const before = document.createElement("pre");
before.textContent = `Before: ${formatValue(difference.before)}`;
item.append(before);
}
if (Object.hasOwn(difference, "after")) {
const after = document.createElement("pre");
after.textContent = `After: ${formatValue(difference.after)}`;
item.append(after);
}
list.append(item);
}
container.append(list);
}
Add CSS classes to make each kind easy to distinguish without relying on color alone:
.diff-added {
border-inline-start: 4px solid #16803c;
background: #eefaf1;
}
.diff-removed {
border-inline-start: 4px solid #b42318;
background: #fff1f0;
}
.diff-changed {
border-inline-start: 4px solid #b54708;
background: #fff7e8;
}
#results li {
margin-block: 0.75rem;
padding: 0.75rem;
}
#results pre {
overflow-x: auto;
white-space: pre-wrap;
}
Finally, connect parsing, comparison, and rendering to the button. Clear previous errors before each attempt and stop if either input fails to parse.
const leftInput = document.querySelector("#left-json");
const rightInput = document.querySelector("#right-json");
const errorOutput = document.querySelector("#error");
const results = document.querySelector("#results");
document.querySelector("#compare-button").addEventListener("click", () => {
errorOutput.textContent = "";
const left = parseInput(leftInput.value, "Original JSON");
const right = parseInput(rightInput.value, "Updated JSON");
if (left.error || right.error) {
errorOutput.textContent = [left.error, right.error]
.filter(Boolean)
.join(" ");
results.replaceChildren();
return;
}
renderDifferences(results, compareJson(left.value, right.value));
});
Remove the leading space before document.querySelector if copying the snippet into a style guide that enforces formatting; it is valid JavaScript either way. The listener assumes the HTML elements exist when the script runs. Place the script after the markup or initialize it after the document has loaded.
Common mistakes and limitations
- Comparing
JSON.stringifyoutput: Equivalent objects can have different key order, so serialized strings are not a reliable structural comparison. - Using
typeofalone: Arrays andnullneed explicit checks when distinguishing JSON types. - Treating missing as
null: A missing field and a field whose value isnullcommunicate different data. - Ignoring array indexes: This implementation treats order as meaningful. It does not identify moves or match objects by an ID.
- Showing only colors: Include text labels such as “added” and “removed” for accessibility and clarity.
- Rendering with
innerHTML: UsetextContentfor values derived from user-provided JSON. - Assuming parsing validates an API response: Parsing confirms JSON syntax, not that the data satisfies a schema or business rule.
The function recursively visits the input. Very large or deeply nested documents can take substantial time or exceed the JavaScript call stack. For untrusted or potentially huge inputs, set appropriate size limits and consider an iterative traversal or a tested diff library. This example is a starting point for a focused browser tool, not a complete solution for every diff format or production workload.
Conclusion
A practical JSON diff tool starts with explicit semantics: parse the inputs, compare objects by keys, compare arrays by index, and keep missing properties distinct from null. A recursive comparison function can turn those rules into clear paths and difference records, while safe DOM rendering makes additions, removals, and changes easy to inspect. Use tests to lock down the behavior that matters for your data.
