All articles
Tutorials

Field-Level Audit Logs: The Diff Primitive Every B2B API Needs

Enterprise customers don't just want an audit log. They want to know which field changed, from what, to what, and by whom. Most implementations get this wrong. This is the diff function that gets it right.

Field-Level Audit Logs: The Diff Primitive Every B2B API Needs cover
9 min read

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.stringify comparison tells you something changed but not what, and breaks on key ordering, undefined, and Date objects.
  • 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 before snapshot 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); // false

There 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:

lib/diff.ts
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:

lib/audit.ts
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:

routes/users.ts
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:

lib/api-client.ts
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:

lib/webhooks.ts
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:

lib/deep-diff.ts
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.

Share𝕏

Writer

  • Wale Bashir

    Technical content writer and full-stack engineer with experience across Web3, AI, and backend systems.

Need help with your technical content?

We help B2B SaaS teams turn complex products into clear documentation and content that developers actually use.

Book a call
Field-Level Audit Logs for B2B APIs: Building the Diff Primitive | Reclear