Skip to content

diagrams-js

A TypeScript port of the diagrams Python library for drawing cloud system architecture diagrams as code

diagrams-js/diagram-diff

core
366 linesSource

Visual diff for diagrams. Compare two versions of a diagram to see added, removed, and modified elements with color-coded side-by-side rendering. Perfect for code reviews, documentation, and architecture evolution tracking.

This skill builds on diagrams-js/getting-started and diagrams-js/json-serialization. Read them first for foundational concepts.

diagrams-js β€” Diagram Diff

Compare two versions of a diagram visually. The diff feature highlights added, removed, and modified elements with color-coded overlays in a side-by-side view.

Use Cases

  • Code reviews: See exactly what infrastructure changed in a PR
  • Documentation: Show evolution of system architecture
  • Auditing: Track what was added or removed between deployments

Core Patterns

Compute and Render a Diff

ts
import { computeDiff, renderDiff } from "diagrams-js";
import { writeFileSync } from "fs";

const before = JSON.parse(await fs.readFile("arch-v1.json", "utf8"));
const after = JSON.parse(await fs.readFile("arch-v2.json", "utf8"));

// Compute the diff
const diff = computeDiff(before, after);
console.log(diff.summary); // { added: 2, removed: 1, modified: 3, unchanged: 5 }

// Render as self-contained HTML
const html = await renderDiff(diff, before, after, { format: "html" });
await fs.writeFile("diff.html", html);

Use Convenience Static Methods

ts
import { Diagram } from "diagrams-js";

// Just compute
const diff = Diagram.diff(before, after);

// Compute and render in one call
const html = await Diagram.renderDiff(before, after, { format: "html" });

Understanding the Diff Result

ts
const diff = computeDiff(before, after);

// Summary counts
console.log(diff.summary);
// { added: 2, removed: 1, modified: 3, unchanged: 5 }

// Check specific node
const nodeDiff = diff.nodes.get("web-server");

if (nodeDiff?.kind === "modified") {
  console.log("Changes:", nodeDiff.changes);
  // ["label: \"Web\" β†’ \"Web v2\"", "type: \"EC2\" β†’ \"Lambda\""]
}

Render Options

ts
const html = await renderDiff(diff, before, after, {
  format: "html", // "html" or "svg"
  theme: "light", // "light" or "dark"
  layout: "side-by-side", // "side-by-side" or "stacked"
  showUnchanged: "show", // "show" (default), "dim", or "hide"
  showLegend: true, // Show color legend
  showSummary: true, // Show change counts
  hoverDetails: true, // Show tooltips on hover
});

Change Types

KindColorDescription
added🟒 GreenNew element in after version
removedπŸ”΄ RedElement deleted in after version
modified🟠 AmberProperties changed (including label changes)
unchangedβšͺ GrayNo changes (shown by default)

Node Matching Algorithm

Nodes are matched using a three-phase approach:

Phase 1: Fingerprint Matching

Nodes with identical (label, provider, type, resource) are matched directly as unchanged or modified.

ts
// Before: { label: "Web Server", provider: "aws", type: "compute", resource: "EC2" }
// After:  { label: "Web Server", provider: "aws", type: "compute", resource: "EC2" }
// Result: unchanged

// Before: { label: "Web Server", provider: "aws", type: "compute", resource: "EC2" }
// After:  { label: "Web Server v2", provider: "aws", type: "compute", resource: "EC2" }
// Result: modified (label change)

Phase 2: Label Fingerprint + Edge Connectivity

Unmatched nodes with the same (provider, type, resource) are matched using edge connectivity:

  • If edge connectivity patterns match β†’ paired and marked as modified (label change)
  • If edge connectivity differs β†’ treated as separate nodes (removed + added)
ts
// Example: worker4 β†’ worker1 with same connectivity
// Before: { label: "worker4", provider: "aws", type: "EC2", connectsTo: ["lb"] }
// After:  { label: "worker1", provider: "aws", type: "EC2", connectsTo: ["lb"] }
// Result: modified (label: "worker4" β†’ "worker1")

// Example: Different connectivity = different nodes
// Before: { label: "worker4", provider: "aws", type: "EC2", connectsTo: ["lb"] }
// After:  { label: "worker1", provider: "aws", type: "EC2", connectsTo: ["db"] }
// Result: removed (worker4) + added (worker1)

Phase 3: Simple Label Fingerprint

Remaining unmatched nodes are matched 1:1 by (provider, type, resource):

  • Same label β†’ unchanged
  • Different labels β†’ modified
ts
// Before: { label: "worker1", provider: "aws", type: "EC2" }
// After:  { label: "worker2", provider: "aws", type: "EC2" }
// Result: modified (label: "worker1" β†’ "worker2")

Diff Options

ts
const diff = computeDiff(before, after, {
  // Ignore layout/position changes (default: true)
  ignore: { position: true },

  // Ignore all metadata
  ignore: { metadata: true },

  // Ignore specific metadata keys
  ignore: { metadata: ["cpu", "memory"] },

  // Ignore specific Graphviz attributes
  ignore: { attrs: ["color", "fillcolor"] },

  // Custom matching function
  matchNodes: (a, b) => a.metadata?.resourceId === b.metadata?.resourceId,
});

Metadata Changes

Diagram-level changes (name, theme, direction, etc.) are tracked separately:

ts
const diff = computeDiff(before, after);

if (diff.meta.name) {
  console.log(`Renamed: "${diff.meta.name.before}" β†’ "${diff.meta.name.after}"`);
}

if (diff.meta.theme) {
  console.log(`Theme changed: ${diff.meta.theme.before} β†’ ${diff.meta.theme.after}`);
}

These appear in the HTML output under "Diagram Options Changed".

CLI Tool

Use @diagrams-js/cli for git workflows:

shell
# Install globally
npm install -g @diagrams-js/cli

# Compare with HEAD (default: diagram-diff.html)
diagrams diff show HEAD diagram.json

# Compare with explicit output
diagrams diff show HEAD diagram.json -o diff.html

# Output to stdout
diagrams diff show HEAD diagram.json --stdout

# Compare branches
diagrams diff show main...feature diagram.json -f html -o diff.html

# List changed files
diagrams diff list HEAD

# Batch diff all changed files
diagrams diff batch main...feature -o ./diffs

Common Mistakes

CRITICAL Forgetting to await renderDiff

Wrong:

ts
const html = renderDiff(diff, before, after); // Missing await
fs.writeFileSync("diff.html", html); // html is Promise, not string

Correct:

ts
const html = await renderDiff(diff, before, after);
fs.writeFileSync("diff.html", html);

renderDiff is async because it renders both diagrams to SVG.

HIGH Expecting modified nodes without matching criteria

Wrong:

ts
// Before: { label: "worker4", provider: "aws", type: "EC2" }
// After:  { label: "worker1", provider: "aws", type: "EC2" }
// Different labels, no edge connectivity match β†’ detected as removed + added

Correct:

ts
// Before: { label: "worker1", provider: "aws", type: "EC2" }
// After:  { label: "worker1-prod", provider: "aws", type: "EC2" }
// Same provider/type with matching connectivity β†’ detected as modified

Keep the same (provider, type, resource) and similar edge connectivity for modification detection.

MEDIUM Confusing modified with removed+added

  • modified: Same (provider, type, resource) with matching edge connectivity, but different label
  • removed + added: Different labels without matching connectivity, or different (provider, type, resource)
ts
if (nodeDiff.kind === "modified") {
  // Label changed but same entity
  console.log("Renamed:", nodeDiff.changes);
}

if (nodeDiff.kind === "removed") {
  // Node was deleted
  console.log("Removed:", nodeDiff.before?.label);
}

if (nodeDiff.kind === "added") {
  // Node is new
  console.log("Added:", nodeDiff.after?.label);
}

Best Practices

Use Stable Node Identifiers

Node matching relies on (label, provider, type, resource), not IDs:

ts
// Good: Consistent identifiers across versions
const web = diagram.add(EC2("Web Server"));

// Good: Meaningful labels that persist
const api = diagram.add(Lambda("API Handler"));

Export JSON for Versioning

Save diagram.toJSON() alongside your code:

ts
// In your build/CI
await diagram.save("architecture.json", { format: "json" });

Focus on Meaningful Changes

Ignore cosmetic changes:

ts
computeDiff(before, after, {
  ignore: {
    position: true, // Layout changes
    attrs: ["color"], // Visual styling
    metadata: ["cost"], // Non-structural metadata
  },
});

Complete Example

ts
import { Diagram, computeDiff, renderDiff } from "diagrams-js";
import { EC2, Lambda } from "diagrams-js/aws/compute";
import { RDS } from "diagrams-js/aws/database";
import { S3 } from "diagrams-js/aws/storage";

// Version 1: Traditional architecture
const v1 = Diagram("Architecture v1", { direction: "TB" });
const web1 = v1.add(EC2("Web Server"));
const db1 = v1.add(RDS("Database"));
web1.to(db1);

// Version 2: Serverless migration
const v2 = Diagram("Architecture v2", { direction: "TB" });
const web2 = v2.add(Lambda("API Handler")); // Changed: EC2 β†’ Lambda
const db2 = v2.add(RDS("Database")); // Unchanged
const storage = v2.add(S3("Assets")); // Added
web2.to(db2);
web2.to(storage);

// Compute diff
const diff = computeDiff(v1.toJSON(), v2.toJSON());
console.log(diff.summary);
// { added: 1, removed: 0, modified: 1, unchanged: 1 }

// Render visual diff
const html = await renderDiff(diff, v1.toJSON(), v2.toJSON(), {
  format: "html",
  theme: "light",
  showUnchanged: "show", // or "dim" to dim unchanged, "hide" to hide them
});

await fs.writeFile("architecture-diff.html", html);

The HTML shows:

  • 🟒 Green: New S3 "Assets" node
  • 🟠 Amber: Changed EC2 β†’ Lambda node
  • βšͺ Unchanged: RDS node (shown normally, use showUnchanged: "dim" to dim)

See Also

  • diagrams-js/json-serialization β€” Export diagrams to JSON
  • diagrams-js/rendering-export β€” General rendering options
  • diagrams-js/diagram-configuration β€” Diagram options (name, theme, direction)