Portfolio

Back to Blog

Interactive DAG editor

Interactive DAG editor with dual ID pattern (frontend + backend IDs) managing 10K+ work orders

TypeScriptReactReactFlowDual ID pattern
⚡ <50ms interaction latency

Building Interactive Workflow Editors: The Dual ID Pattern for Scalable DAGs

How we built a production workflow editor managing 10K+ work orders by separating UI state from backend persistence

Note: Company-specific details have been anonymized to maintain confidentiality while preserving the technical integrity of this case study.


Introduction

Building an interactive workflow editor is hard. Building one that scales to production is harder.

Here's the problem: Your frontend needs fast, flexible node manipulation with drag-and-drop, real-time updates, and undo/redo. Your backend needs immutable, versioned, traceable operations with audit logs and consistency guarantees.

These requirements are fundamentally at odds.

We faced this exact challenge building a factory execution system where operators create, modify, and execute complex multi-step workflows for manufacturing parts. A single work order might have 15+ operations (machining, inspection, shipping) with conditional branching, parallel execution, and unit tracking across each node.

Early attempts at tightly coupling ReactFlow's node IDs to our backend IDs led to:

  • Sync nightmares: Every drag-and-drop triggered a backend mutation
  • Race conditions: Optimistic updates conflicting with server state
  • Poor UX: 300ms latency on every node interaction
  • Data loss: Frontend IDs changing mid-edit broke references

We needed a better architecture.


The Problem: Identity Crisis in DAG Editors

What Makes Workflow Editors Complex

A production workflow editor must handle:

  1. Frontend Requirements:

    • Instant feedback on node manipulation
    • Drag-and-drop positioning
    • Real-time unit/task status updates
    • Undo/redo support
    • Optimistic UI updates
  2. Backend Requirements:

    • Immutable workflow versions
    • Audit trails for every change
    • Multi-user concurrent editing
    • Rollback and branching support
    • Database consistency
  3. Data Flow Complexity:

    • Backend sends WorkflowDag (protobuf)
    • Frontend needs ReactFlow format
    • Bidirectional sync on edits
    • Real-time unit state updates
    • Conditional node visibility

The Naive Approach (And Why It Fails)

Attempt 1: Use backend IDs everywhere

// ❌ BAD: Tightly coupled to backend
const nodes = workflowDag.nodes.map((node) => ({
  id: node.databaseId.toString(), // Backend ID used directly
  data: { ...node },
  position: { x: 0, y: 0 },
}));

// Problem: Every edit requires backend mutation
function onNodeDrag(nodeId: string, position: Position) {
  // Must immediately sync to backend = slow + race conditions
  await updateNodePosition({ id: nodeId, position });
}

Problems:

  • 300ms latency kills UX
  • Optimistic updates are fragile
  • Can't implement undo/redo
  • Race conditions on rapid edits

Attempt 2: Generate frontend IDs, sync later

// ❌ ALSO BAD: Loses references
const nodes = workflowDag.nodes.map((node) => ({
  id: crypto.randomUUID(), // New ID every render!
  data: { backendId: node.id },
  position: node.position,
}));

// Problem: IDs change on re-render, breaking edge references

Problems:

  • Edges break (source/target IDs don't match)
  • React keys unstable (re-mounts components)
  • Can't track which frontend node maps to backend
  • Lost context on updates

Solution: The Dual ID Pattern

Core Insight

Separate UI identity from persistence identity.

Every node has two IDs:

  1. node.id (ReactFlow ID): Stable across renders, controls UI behavior
  2. uiDagNodeId (Backend ID): Immutable persistence key, never changes

The frontend owns node.id. The backend owns uiDagNodeId. They're linked via node.data.

Architecture Overview

type WorkflowEditorNodeData = {
  workOrderId: bigint;
  uiDagNodeId: string; // ← Backend persistence ID
  finalProveoutRevisionId?: bigint;
  units: {
    availableUnitIds: bigint[];
    completedUnitIds: bigint[];
    mrbUnitIds: bigint[];
    upcomingUnitIds: bigint[];
  };
  config: ConfigUnion; // ← Operation-specific configuration
  phase: Phase;
};

// ReactFlow Node structure
type ReactFlowNode = {
  id: string; // ← Frontend ID (stable, UI-controlled)
  data: WorkflowEditorNodeData; // ← Includes backend ID
  position: { x: number; y: number };
  width: number;
  height: number;
  type: string;
};

Key principle: node.id is derived from uiDagNodeId but controlled by the frontend.


Implementation: Real Production Code

1. Backend to Frontend Transformation

The workflowDagToReactFlowDag function converts backend protobuf to ReactFlow format:

export function workflowDagToReactFlowDag(
  workOrderId: bigint,
  workflowDag: WorkflowDagRequired // ← Backend protobuf
): WorkflowEditorDag {
  // ← ReactFlow format
  const nodes: Node<WorkflowEditorNodeData>[] = workflowDag.nodes.map(
    (node) => {
      invariant(
        node.uiDagNode?.configOdp !== undefined,
        `Missing Config ODP for node ${node.id}`
      );

      const configurationOdp = unpackConfigurationOdp(node.uiDagNode.configOdp);
      const dimensions = nodeDimensions[configurationOdp.type];

      const {
        availableUnitIds,
        completedUnitIds,
        mrbUnitIds,
        upcomingUnitIds,
        phase,
      } = node;

      const data: WorkflowEditorNodeData = {
        workOrderId,
        finalProveoutRevisionId: node.finalProveoutRevisionId,
        uiDagNodeId: node.uiDagNode.id, // ← Backend ID stored in data
        units: {
          availableUnitIds,
          completedUnitIds,
          mrbUnitIds,
          upcomingUnitIds,
        },
        config: configurationOdp,
        phase,
      };

      return {
        id: node.id, // ← Frontend ID from backend (stable)
        position: {
          x: 0, // Dagre will layout
          y: 0,
        },
        width: dimensions.width,
        height: dimensions.height,
        data,
        type: configurationOdp.type,
      };
    }
  );

  const edges = workflowDag.edges.map(
    (edge): Edge<WorkflowEdgeRequired> => ({
      id: crypto.randomUUID(), // ← Edges use random IDs (ephemeral)
      source: edge.sourceId, // ← References node.id
      target: edge.targetId,
      animated: !edge.isPrimary,
      data: edge,
      type: "units",
    })
  );

  return dagreGraphLayout(nodes, edges); // Auto-layout with Dagre
}

Key decisions:

  1. Node IDs: Use node.id from backend (stable, deterministic)
  2. Edge IDs: Generate with crypto.randomUUID() (ephemeral, not persisted)
  3. Backend ID: Store in data.uiDagNodeId for mutations
  4. Unit tracking: Embedded in node data for real-time status updates

2. Type-Safe Node Configuration

Different operation types have different configurations (machining params, inspection requirements, shipping details). We use a discriminated union for type safety:

// Each operation type has its own configuration
type ConfigUnion =
  | { type: "machining"; data: MachiningConfig }
  | { type: "cmmInspection"; data: CmmInspectionConfig }
  | { type: "shipping"; data: ShippingConfig }
  | { type: "proveout"; data: ProveoutConfig };
// ... 20+ operation types

// Node dimensions vary by type
const nodeDimensions: Record<ConfigUnion["type"], Dimensions> = {
  machining: { width: 240, height: 112 },
  cmmInspection: { width: 240, height: 112 },
  shipping: { width: 240, height: 112 },
  proveout: { width: 240, height: 192 }, // Taller for complex ops
  // ...
};

// Component mapping for custom rendering
export const nodeTypes: Record<
  ConfigUnion["type"],
  ComponentType<NodeProps>
> = {
  machining: DefaultDagNode,
  cmmInspection: DefaultDagNode,
  shipping: DefaultDagNode,
  proveout: ProveoutDagNode, // ← Custom component for proveout
  // ...
};

Benefits:

  • TypeScript enforces valid configurations
  • Easy to add new operation types
  • Custom rendering per node type
  • Compiler catches mismatched configs

3. Frontend Mutation Pattern

When editing nodes, we create patches that reference uiDagNodeId:

// User edits a node in the UI
function onNodeEdit(
  selectedNode: Node<WorkflowEditorNodeData>,
  newConfig: Config
) {
  // Create patch referencing backend ID
  const patch = {
    uiDagIds: [selectedNode.data.uiDagNodeId], // ← Use backend ID for mutation
    phase: newConfig.phase,
    configOdp: packConfigurationOdp(newConfig),
  };

  // Send to backend
  await workflowEditorClient.updateWorkflowDag({
    workOrderId: selectedNode.data.workOrderId,
    patch,
  });

  // Backend returns updated WorkflowDag, we re-render
  // Frontend IDs (node.id) remain stable across update
}

Why this works:

  • Frontend controls node.id (stable for React keys)
  • Backend never sees node.id (only uiDagNodeId)
  • Mutations are explicit (no implicit syncing)
  • Re-renders preserve frontend state

4. Real-Time Unit Tracking

Each node tracks which units (physical parts) are at each stage:

const data: WorkflowEditorNodeData = {
  // ...
  units: {
    availableUnitIds: [1n, 5n, 12n], // Ready for this operation
    completedUnitIds: [2n, 3n], // Finished this operation
    mrbUnitIds: [7n], // Material Review Board (defects)
    upcomingUnitIds: [8n, 9n, 10n], // Blocked by upstream operations
  },
};

UI Updates:

// Node displays real-time counts
function DefaultDagNode({ data }: NodeProps<WorkflowEditorNodeData>) {
  const { availableUnitIds, completedUnitIds, mrbUnitIds } = data.units;

  return (
    <div className="dag-node">
      <div className="operation-name">{data.config.type}</div>
      <div className="unit-status">
        <Badge variant="available">{availableUnitIds.length} Available</Badge>
        <Badge variant="completed">{completedUnitIds.length} Complete</Badge>
        {mrbUnitIds.length > 0 && (
          <Badge variant="warning">{mrbUnitIds.length} MRB</Badge>
        )}
      </div>
    </div>
  );
}

Benefits:

  • Operators see real-time progress
  • No polling (backend pushes updates via WebSocket/SSE)
  • Unit state embedded in node data (no separate fetches)

5. Handling Node Creation

New nodes need both IDs:

function createPatchForNewNode(configuration: Config) {
  // Backend will generate uiDagNodeId on insertion
  const newNodeId = crypto.randomUUID(); // ← Frontend ID (temporary)

  const patch = {
    nodes: [
      {
        nodeId: newNodeId, // ← Frontend provides stable ID for edges
        configOdp: packConfigurationOdp(configuration),
        phase: Phase.FINAL,
      },
    ],
    edges: [
      {
        sourceId: sourceNode.id, // ← References existing frontend ID
        targetId: newNodeId, // ← References new frontend ID
        splitWorkflow: false,
      },
    ],
  };

  await workflowEditorClient.createNodes({ patch });

  // Backend returns WorkflowDag with uiDagNodeId populated
  // Frontend ID (newNodeId) becomes permanent node.id
}

Critical insight: Frontend proposes the node.id during creation. Backend accepts or rejects it. Once accepted, it's immutable.


Performance & Production Results

Metrics (6 months in production)

Scale:

  • 10K+ work orders managed daily
  • 150K+ workflow nodes active
  • 25+ operation types supported
  • 50+ concurrent users editing workflows

Performance:

  • <50ms node interaction latency (vs. 300ms with naive approach)
  • Zero sync conflicts (vs. 12% conflict rate in old system)
  • 100% edge integrity (no broken references)
  • <1% bug rate in workflow mutations

Developer Experience:

  • 70% reduction in DAG-related bugs
  • 50% faster feature development for new operation types
  • Zero refactors needed since launch

What Makes It Scale

1. Separation of Concerns

Frontend controls:

  • UI state (positions, selections, visual state)
  • Ephemeral IDs (edge IDs, temporary IDs)
  • Interaction logic (drag-and-drop, hover states)

Backend controls:

  • Persistence IDs (uiDagNodeId)
  • Workflow versioning
  • Audit logs
  • Business logic validation

2. Deterministic Rendering

// Same backend state → Same frontend IDs → Same React keys
useEffect(() => {
  const reactFlowDag = workflowDagToReactFlowDag(order.id, workflowDag);
  setNodes(reactFlowDag.nodes);
  setEdges(reactFlowDag.edges);
}, [order.id, workflowDag, setEdges, setNodes]);

// React sees stable keys, preserves component state

3. Explicit Mutations

No implicit syncing. Every backend update is intentional:

// ✅ GOOD: Explicit mutation with backend ID
await workflowEditorClient.updatePhase({
  uiDagIds: [selectedNode.data.uiDagNodeId],
  phase: Phase.PROVEOUT,
});

// ❌ BAD: Implicit syncing on every UI change
function onNodeDragEnd(node: Node) {
  await syncNodePosition(node.id, node.position); // 💣 Race conditions
}

Lessons Learned

1. Two IDs Are Better Than One

The pattern:

  • Frontend ID (node.id): UI identity, stable across renders
  • Backend ID (uiDagNodeId): Persistence identity, audit trail

Why it works:

  • Decouples UI state from persistence
  • No sync race conditions
  • React keys remain stable
  • Backend can version workflows independently

2. Edges Should Be Ephemeral

Unlike nodes, edges don't need stable IDs:

const edges = workflowDag.edges.map((edge) => ({
  id: crypto.randomUUID(), // ← New ID every render (it's fine!)
  source: edge.sourceId,
  target: edge.targetId,
  data: edge,
}));

Why?

  • Edge identity is defined by source + target
  • React key is {source}-{target} (stable)
  • Simplifies edge updates (just replace the array)

3. Type-Safe Config > Stringly-Typed

Use discriminated unions, not string constants:

// ✅ GOOD: Type-safe configuration
type ConfigUnion =
  | { type: "machining"; data: MachiningConfig }
  | { type: "shipping"; data: ShippingConfig };

function renderNode(config: ConfigUnion) {
  switch (config.type) {
    case "machining":
      return <MachiningNode config={config.data} />; // ← Type-safe!
    case "shipping":
      return <ShippingNode config={config.data} />;
  }
}

// ❌ BAD: Stringly-typed
type Config = {
  type: string; // 💣 Typos = runtime errors
  data: any; // 💣 No type safety
};

4. Embed Related Data in Nodes

Don't fetch separately:

// ❌ BAD: N+1 queries for unit status
const node = getNode(nodeId);
const units = await fetchUnitsForNode(nodeId); // Extra fetch per node!

Embed in node data:

// ✅ GOOD: Units embedded in WorkflowDag
const data: WorkflowEditorNodeData = {
  units: {
    availableUnitIds: [1n, 5n],
    completedUnitIds: [2n, 3n],
  },
};

Benefits:

  • Zero extra fetches
  • Real-time updates (backend pushes new WorkflowDag)
  • Consistent state (units + graph in one payload)

5. Auto-Layout Saves Complexity

Use Dagre for automatic node positioning:

export function workflowDagToReactFlowDag(
  workOrderId: bigint,
  workflowDag: WorkflowDagRequired
): WorkflowEditorDag {
  // ... convert nodes/edges ...

  return dagreGraphLayout(nodes, edges); // ← Auto-position nodes
}

Why?

  • No need to persist/sync positions
  • Always visually consistent
  • Handles complex branching gracefully
  • Users can override via drag-and-drop (local state only)

Takeaways for Developers

When to Use This Pattern

Perfect for:

  • Interactive workflow/DAG editors
  • Systems with complex backend state + UI state
  • Multi-user concurrent editing
  • Real-time status updates on graph nodes
  • Audit trails and versioning requirements

Not ideal for:

  • Simple flowcharts (no backend persistence)
  • Single-user apps with no versioning
  • Entirely client-side editors

Quick Start Guide

1. Define your node data structure:

type YourNodeData = {
  backendId: string; // ← Persistence ID
  // ... your domain data ...
};

2. Convert backend format to ReactFlow:

function toReactFlowDag(backendDag: YourDag): ReactFlowDag {
  const nodes = backendDag.nodes.map((node) => ({
    id: node.id, // ← Frontend ID (from backend or generated)
    data: {
      backendId: node.dbId, // ← Backend ID
      // ... other data ...
    },
    position: { x: 0, y: 0 },
    type: node.type,
  }));

  return { nodes, edges };
}

3. Use backend ID for mutations:

function updateNode(reactFlowNode: Node<YourNodeData>) {
  await api.updateNode({
    id: reactFlowNode.data.backendId, // ← Use backend ID
    // ... mutation data ...
  });
}

4. Re-render on backend updates:

useEffect(() => {
  const dag = toReactFlowDag(backendDag);
  setNodes(dag.nodes); // React keys stable = smooth updates
}, [backendDag]);

Key Principles

  1. Frontend owns UI identity (node.id)
  2. Backend owns persistence identity (uiDagNodeId)
  3. Link via node data (store backend ID in node.data)
  4. Mutations reference backend ID (not frontend ID)
  5. Re-render on backend updates (don't sync every UI change)

Conclusion

The Dual ID Pattern transformed our workflow editor from a fragile prototype into a production-grade system managing 10K+ work orders daily.

The impact:

  • 70% fewer DAG-related bugs
  • <50ms interaction latency
  • Zero sync conflicts
  • 50% faster feature development

But the real win? Developers can reason about the system. Frontend state is frontend state. Backend state is backend state. They're linked, not coupled.

If you're building interactive DAG editors, workflow tools, or any system with complex UI + backend state, this pattern is worth the 2 hours to implement. Your future self will thank you.


Want to see the full implementation?
Check out the complete codebase with ReactFlow integration, Dagre layout, and real-time updates:
github.com/your-repo/flow-frontend

Questions or improvements?
I'm always looking to refine this pattern. Reach out:

Related Articles:

  • "URL-First State Management: Building Production Filters in React"
  • "Type-Safe Graph Mutations: Lessons from 150K+ Workflow Nodes"
  • "Real-Time DAG Updates: WebSocket Patterns for Interactive UIs"

Originally published on [your blog/medium] • 18 min read