X Xerobit

Detecting Changes in JSON Data — Audit Logs, Diffs, and Change Tracking

Detecting what changed in a JSON document is essential for audit logs, versioning, and syncing. Here's how to track JSON changes with diff algorithms, store change history, and...

Mian Ali Khalid · · 5 min read
Use the tool
JSON Diff
Compare two JSON objects structurally with field-by-field diff.
Open JSON Diff →

Change detection in JSON is the foundation of audit trails, collaborative editing, and optimistic concurrency. The goal: given two versions of a document, know precisely what changed, when, and by whom.

Use the JSON Diff Tool to visually compare two JSON documents.

The basic pattern

// Store original, apply changes, diff to get what changed:
async function updateDocument(id, updates) {
  const original = await db.find(id);
  const updated = { ...original, ...updates };
  
  const changes = diffJson(original, updated);
  
  await db.save(updated);
  await auditLog.record({ id, changes, timestamp: new Date(), user: currentUser });
  
  return updated;
}

Generating a change record

function diffJson(before, after, path = '') {
  const changes = [];
  
  if (typeof before !== 'object' || typeof after !== 'object' ||
      before === null || after === null) {
    if (before !== after) {
      changes.push({ path, before, after, op: 'replace' });
    }
    return changes;
  }
  
  // Detect removed keys
  for (const key of Object.keys(before)) {
    const fullPath = path ? `${path}.${key}` : key;
    if (!(key in after)) {
      changes.push({ path: fullPath, before: before[key], op: 'remove' });
    } else {
      changes.push(...diffJson(before[key], after[key], fullPath));
    }
  }
  
  // Detect added keys
  for (const key of Object.keys(after)) {
    if (!(key in before)) {
      const fullPath = path ? `${path}.${key}` : key;
      changes.push({ path: fullPath, after: after[key], op: 'add' });
    }
  }
  
  return changes;
}

const before = { name: 'Alice', age: 30, role: 'user' };
const after  = { name: 'Alice', age: 31, email: 'alice@example.com' };

diffJson(before, after);
// [
//   { path: 'age', before: 30, after: 31, op: 'replace' },
//   { path: 'role', before: 'user', op: 'remove' },
//   { path: 'email', after: 'alice@example.com', op: 'add' }
// ]

Audit log schema

CREATE TABLE audit_logs (
  id          BIGSERIAL PRIMARY KEY,
  entity_type VARCHAR(100) NOT NULL,   -- 'user', 'order', 'product'
  entity_id   VARCHAR(100) NOT NULL,
  operation   VARCHAR(10) NOT NULL,    -- 'create', 'update', 'delete'
  changes     JSONB,                   -- the diff
  before_data JSONB,                   -- snapshot before
  after_data  JSONB,                   -- snapshot after
  changed_by  VARCHAR(100),
  changed_at  TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX ON audit_logs (entity_type, entity_id);
CREATE INDEX ON audit_logs (changed_at);

Storing both the diff (changes) and snapshots (before_data, after_data) lets you:

  • Quickly see what changed (read the diff)
  • Reconstruct any historical state (replay snapshots)
  • Generate human-readable changelogs

Storing change history in PostgreSQL

// Using PostgreSQL with JSONB:
async function updateWithAudit(entityType, entityId, updates, userId) {
  const before = await db.query(
    `SELECT data FROM ${entityType}s WHERE id = $1`,
    [entityId]
  );
  
  const beforeData = before.rows[0].data;
  const afterData = { ...beforeData, ...updates };
  
  await db.transaction(async (trx) => {
    await trx.query(
      `UPDATE ${entityType}s SET data = $1 WHERE id = $2`,
      [afterData, entityId]
    );
    
    await trx.query(
      `INSERT INTO audit_logs 
       (entity_type, entity_id, operation, changes, before_data, after_data, changed_by)
       VALUES ($1, $2, 'update', $3, $4, $5, $6)`,
      [entityType, entityId, JSON.stringify(diffJson(beforeData, afterData)),
       beforeData, afterData, userId]
    );
  });
  
  return afterData;
}

Event sourcing approach

Instead of storing the current state and diffs separately, event sourcing stores only the events (changes):

// Event store — append only:
const eventStore = {
  async append(streamId, eventType, data) {
    await db.query(
      `INSERT INTO events (stream_id, event_type, data, created_at)
       VALUES ($1, $2, $3, NOW())`,
      [streamId, eventType, JSON.stringify(data)]
    );
  },
  
  async getStream(streamId) {
    const result = await db.query(
      `SELECT * FROM events WHERE stream_id = $1 ORDER BY created_at`,
      [streamId]
    );
    return result.rows;
  },
};

// Usage:
await eventStore.append(`user:${userId}`, 'UserEmailChanged', {
  before: 'old@example.com',
  after: 'new@example.com',
});

await eventStore.append(`user:${userId}`, 'UserRoleChanged', {
  before: 'viewer',
  after: 'editor',
});

// Reconstruct state by replaying events:
async function getUserState(userId) {
  const events = await eventStore.getStream(`user:${userId}`);
  return events.reduce((state, event) => {
    switch (event.event_type) {
      case 'UserEmailChanged':
        return { ...state, email: event.data.after };
      case 'UserRoleChanged':
        return { ...state, role: event.data.after };
      default:
        return state;
    }
  }, {});
}

Detecting concurrent modifications (optimistic concurrency)

// Add a version field to detect conflicts:
async function updateWithVersion(id, updates, expectedVersion) {
  const result = await db.query(
    `UPDATE documents 
     SET data = $1, version = version + 1
     WHERE id = $2 AND version = $3
     RETURNING *`,
    [{ ...updates }, id, expectedVersion]
  );
  
  if (result.rows.length === 0) {
    throw new Error('Conflict: document was modified by another process');
  }
  
  return result.rows[0];
}

// Client sends the version it last read:
const doc = await getDocument(id);      // Returns { data: {...}, version: 5 }
const updated = await updateWithVersion(id, changes, doc.version);
// If version changed to 6 between read and write, throws conflict error

Change notifications with webhooks

// Emit change events that external systems can subscribe to:
async function updateDocument(id, changes) {
  const before = await db.find(id);
  const after = { ...before, ...changes };
  
  const diff = diffJson(before, after);
  await db.save(after);
  
  // Notify webhooks:
  await webhookQueue.enqueue({
    event: 'document.updated',
    payload: {
      id,
      changes: diff,
      timestamp: new Date().toISOString(),
    },
  });
  
  return after;
}

MongoDB change streams

MongoDB has built-in change detection:

const changeStream = db.collection('users').watch([
  { $match: { operationType: { $in: ['update', 'replace'] } } },
]);

for await (const change of changeStream) {
  console.log('Document changed:', change.documentKey._id);
  console.log('Updated fields:', change.updateDescription?.updatedFields);
  console.log('Removed fields:', change.updateDescription?.removedFields);
}

Related posts

Related tool

JSON Diff

Compare two JSON objects structurally with field-by-field diff.

Written by Mian Ali Khalid. Part of the Data & Format pillar.