TL;DR
- "User updated their profile" is not an audit log. Enterprise customers want which field changed, from what value, to what value, and by whom. That requires a diff function, not a log statement.
===compares object references, not values. Two separately created objects with identical content are never===.JSON.stringifycomparison tells you something changed but not what, and breaks on key ordering,undefined, andDateobjects.- A diff function returns
{ field: { oldValue, newValue } }for every changed key. That record drives audit log entries, PATCH request bodies, and webhook change payloads from a single implementation. - Always read the
beforesnapshot from the database immediately before the update, not from cache. A stale snapshot produces an audit entry describing a transition that never happened.
Every enterprise sales call eventually surfaces the same request: "Can you show us who changed what and when?" Most teams answer yes and then ship "User X updated Resource Y": a timestamp and an actor ID, with no detail about what actually changed.
This article covers how to produce field-level audit records that name the specific fields, their before values, and their after values, and the diff function that makes this possible.
What the output should look like
Before writing any code, it helps to be clear about what a useful audit entry looks like. A basic log entry records that something happened:
{
"timestamp": "2026-05-31T14:22:01.000Z",
"actorId": "usr_admin_01",
"action": "user.updated",
"resourceId": "usr_alice_99"
}An enterprise-grade entry names exactly what changed:
{
"timestamp": "2026-05-31T14:22:01.000Z",
"actorId": "usr_admin_01",
"resourceType": "user",
"resourceId": "usr_alice_99",
"changes": {
"role": { "oldValue": "viewer", "newValue": "editor" }
}
}The changes field is what separates a queryable audit trail from a timestamp list. With it, you can answer "when did Alice's role change?" by filtering on changes.role. Without it, you can only answer "when did something change on Alice's record?"
Producing the changes field means comparing the object before the update and after it, then returning only the keys that differ along with both values. That is exactly what a diff function does.
Why naive comparisons fail
The first instinct is to compare the before and after objects directly. In JavaScript, === performs reference comparison on objects, not value comparison. Two separately allocated objects with identical content are never ===:
const a = { name: "Alice", role: "editor" };
const b = { name: "Alice", role: "editor" }; // same content, different object
console.log(a === b); // falseThere is no combination of identical values that makes this return true. The operator is comparing memory addresses, not field values, so it cannot tell you what changed or even whether anything did.
JSON.stringify gets past the reference problem by comparing serialized strings:
const changed = JSON.stringify(before) !== JSON.stringify(after);This correctly tells you that something changed. It still gives you nothing about what changed, only a boolean. It also breaks in ways that matter at runtime: keys serialized in different orders produce different strings for identical objects, undefined values are silently dropped, Date objects lose their type, and circular references throw. None of these is theoretical; each appears in real domain objects.
Neither approach can produce { role: { oldValue: "viewer", newValue: "editor" } }. You need something that iterates the fields.
Writing a field-level diff function
A diff function iterates all keys across both objects and returns only the ones where the values differ. Each entry in the result holds the old and new value, which is exactly the shape the audit entry needs:
export interface FieldChange<T = unknown> {
oldValue: T;
newValue: T;
}
export type DiffResult<T extends object> = Partial<Record<keyof T, FieldChange>>;
export function diff<T extends object>(before: T, after: T): DiffResult<T> {
const changes: DiffResult<T> = {};
const allKeys = new Set([
...Object.keys(before),
...Object.keys(after),
]) as Set<keyof T>;
for (const key of allKeys) {
if (!isEqual(before[key], after[key])) {
changes[key] = { oldValue: before[key], newValue: after[key] } as FieldChange;
}
}
return changes;
}
function isEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a === null || b === null) return false;
if (typeof a !== typeof b) return false;
if (typeof a !== "object") return false;
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((item, i) => isEqual(item, (b as unknown[])[i]));
}
if (Array.isArray(a) !== Array.isArray(b)) return false;
const aKeys = Object.keys(a as object);
const bKeys = Object.keys(b as object);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((k) =>
isEqual(
(a as Record<string, unknown>)[k],
(b as Record<string, unknown>)[k]
)
);
}isEqual handles the cases JSON.stringify misses: Date comparison by value, type mismatches, undefined vs absent keys, and nested objects.
Running it against the role change from the audit entry above:
const before = { name: "Alice", role: "viewer", active: true };
const after = { name: "Alice", role: "editor", active: true };
const changes = diff(before, after);
// { role: { oldValue: "viewer", newValue: "editor" } }Only role appears. name and active are identical and excluded. The output is exactly the changes field the audit entry needs.
Writing structured audit log entries
With diff in place, the audit handler takes a before and after snapshot, computes the diff, and writes a structured entry only when something actually changed:
import { diff, type DiffResult } from "./diff";
interface AuditEntry {
timestamp: string;
actorId: string;
resourceType: string;
resourceId: string;
changes: DiffResult<object>;
}
export async function logChange<T extends object>(
actorId: string,
resourceType: string,
resourceId: string,
before: T,
after: T
): Promise<void> {
const changes = diff(before, after);
if (Object.keys(changes).length === 0) return;
await db.auditLog.create({
data: {
timestamp: new Date().toISOString(),
actorId,
resourceType,
resourceId,
changes,
} satisfies AuditEntry,
});
}Wire it into a PATCH route by reading before from the database, applying the update, then passing both snapshots to logChange:
app.patch("/users/:id", protect, async (req, res) => {
const before = await db.user.findUnique({ where: { id: req.params.id } });
if (!before) {
return res.status(404).json({ error: "Not found" });
}
const after = await db.user.update({
where: { id: req.params.id },
data: req.body,
});
await logChange(req.user!.id, "user", req.params.id, before, after);
res.json(after);
});The resulting entry for the role change:
{
"timestamp": "2026-05-31T14:22:01.000Z",
"actorId": "usr_admin_01",
"resourceType": "user",
"resourceId": "usr_alice_99",
"changes": {
"role": { "oldValue": "viewer", "newValue": "editor" }
}
}A user object with 20 fields where one changed produces a one-key changes object. That entry is queryable: WHERE changes->>'role' IS NOT NULL returns every role change across all users and all time.
Why stale before-snapshots corrupt your diff
The diff is only as accurate as the before snapshot. The most common mistake is reading it from a cache:
// Wrong: may reflect state from minutes ago
const before = await cache.get(`user:${userId}`);
// Right: fresh, immediately before the write
const before = await db.user.findUnique({ where: { id: userId } });Consider a cached snapshot showing role: "viewer" while the database already holds role: "moderator", updated by a concurrent request. The diff produces viewer -> editor. The actual transition was moderator -> editor. The audit log is wrong and cannot be corrected after the fact.
Always read before from the database immediately before the update you intend to audit. The extra read is the cost of accurate records.
Tip
When updating multiple fields in one request, wrap the read and write in a transaction so no other update can change the record between your before read and your after write.
PATCH bodies and webhook payloads
The DiffResult that feeds logChange also covers two other patterns without additional work.
PATCH bodies. Extract the new values from the diff to send only what changed. A form with 20 fields where the user changed one sends a one-key request body, and concurrent edits to different fields no longer overwrite each other:
const changes = diff(localBefore, localAfter);
const patch = Object.fromEntries(
Object.entries(changes).map(([key, change]) => [key, change!.newValue])
);
await fetch(`/api/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});Webhook payloads. Include the diff alongside the full updated resource so subscribers can react to specific field changes without maintaining their own copy of the previous state:
await sendWebhook({
event: "user.updated",
timestamp: new Date().toISOString(),
data: after,
changes: diff(before, after),
});A subscriber checks event.changes.role to detect a role change without storing and comparing the full user record on every event.
When this breaks down
Array ordering. isEqual treats arrays as ordered sequences. [1, 2, 3] and [3, 2, 1] are different. For unordered sets like tag lists or member IDs, sort both arrays before diffing or compare as sets explicitly.
Type coercion. "30" and 30 are not equal and will appear as a change. This is intentional: a type change is a real change. If your input layer returns strings for numeric fields, normalize before diffing.
Deeply nested objects. When a nested field changes, diff records the entire parent key as changed. For audit entries that need to name the exact nested path, a path-based diff is more appropriate:
export interface PathChange {
path: string;
oldValue: unknown;
newValue: unknown;
}
export function deepDiff(before: unknown, after: unknown, path = ""): PathChange[] {
if (before === after) return [];
if (typeof before !== "object" || before === null ||
typeof after !== "object" || after === null) {
return [{ path: path || ".", oldValue: before, newValue: after }];
}
const allKeys = new Set([
...Object.keys(before as object),
...Object.keys(after as object),
]);
return [...allKeys].flatMap((key) => {
const childPath = path ? `${path}.${key}` : key;
return deepDiff(
(before as Record<string, unknown>)[key],
(after as Record<string, unknown>)[key],
childPath
);
});
}deepDiff produces entries like { path: "address.city", oldValue: "Lagos", newValue: "Abuja" } instead of recording the entire address object as changed. Use it when the audit entry needs to name the exact field rather than the parent object.
