Overview
Subgraphs let users group nodes into reusable, nestable components. Each subgraph is its own LGraph with a UUID. For the user-facing guide, see Subgraphs.
Node Identifiers
ComfyUI uses three distinct node identifier types. Using the wrong one causes silent failures.
| Type | Format | Used for |
|---|
node.id | 42 (number) | Local to its immediate graph level. graph.getNodeById(id) |
| Execution ID | "1:2:3" (colon-separated string) | Backend progress messages, UNIQUE_ID |
| Locator ID | "<uuid>:<localId>" or "<localId>" | UI state: badges, errors, images |
To construct a node’s locator ID from within an extension:
function getLocatorId(node) {
const graphId = node.graph?.id
return graphId ? `${graphId}:${node.id}` : String(node.id)
}
Traversing Nodes
Current layer only
for (const node of app.graph.nodes) {
console.log(node.id, node.type)
}
All nodes recursively
To walk into nested subgraphs, use a recursive helper that calls a callback on every node:
function walkGraph(graph, callback) {
for (const node of graph.nodes ?? []) {
callback(node, graph)
if (node.subgraph) walkGraph(node.subgraph, callback)
}
}
Full example:
import { app } from "../../scripts/app.js"
function walkGraph(graph, callback) {
for (const node of graph.nodes ?? []) {
callback(node, graph)
if (node.subgraph) walkGraph(node.subgraph, callback)
}
}
app.registerExtension({
name: "MyExtension.SubgraphWalker",
async afterConfigureGraph() {
walkGraph(app.graph, (node, graph) => {
console.log(`[${graph.id ?? "root"}] node ${node.id}: ${node.type}`)
})
}
})
Root vs Active Graph
| You want to… | Use |
|---|
| Operate on all nodes in the workflow | app.graph (root) |
| Operate on only the visible layer | app.canvas?.graph |
| Access a specific subgraph | someNode.subgraph |
// All nodes (including nested subgraphs)
walkGraph(app.graph, (node) => { /* ... */ })
// Only nodes the user currently sees
for (const node of app.canvas?.graph?.nodes ?? []) { /* ... */ }
Events
Subgraph-level events
Dispatched on subgraph.events:
| Event | Payload | When |
|---|
widget-promoted | { widget, subgraphNode } | Widget promoted to parent node |
widget-demoted | { widget, subgraphNode } | Widget removed from parent node |
input-added | { input } | Input slot added |
removing-input | { input, index } | Input slot being removed |
output-added | { output } | Output slot added |
removing-output | { output, index } | Output slot being removed |
renaming-input | { input, index, oldName, newName } | Input slot renamed |
renaming-output | { output, index, oldName, newName } | Output slot renamed |
Canvas-level events
Dispatched on app.canvas.canvas (the HTML canvas element):
| Event | Payload | When |
|---|
subgraph-opened | { subgraph, closingGraph, fromNode } | User navigates into a subgraph |
subgraph-converted | { subgraphNode } | Selection converted to a subgraph |
Listening pattern
import { app } from "../../scripts/app.js"
app.registerExtension({
name: "MyExtension.SubgraphEvents",
async setup() {
app.canvas.canvas.addEventListener("subgraph-opened", (e) => {
const { subgraph, fromNode } = e.detail
console.log(`Opened subgraph from node ${fromNode.id}`)
})
}
})
When a SubgraphInput connects to a widget inside a subgraph, a copy of that widget appears on the parent subgraph node. This fires widget-promoted. Removing the connection fires widget-demoted.
Widget promotion behavior is still evolving and may change in future releases.
import { app } from "../../scripts/app.js"
function walkGraph(graph, callback) {
for (const node of graph.nodes ?? []) {
callback(node, graph)
if (node.subgraph) walkGraph(node.subgraph, callback)
}
}
app.registerExtension({
name: "MyExtension.WidgetPromotion",
async afterConfigureGraph() {
walkGraph(app.graph, (node) => {
if (!node.subgraph) return
if (node._promCleanup) node._promCleanup.abort()
const controller = new AbortController()
node._promCleanup = controller
const { signal } = controller
node.subgraph.events.addEventListener("widget-promoted", (e) => {
console.log(`Widget "${e.detail.widget.name}" promoted`)
}, { signal })
node.subgraph.events.addEventListener("widget-demoted", (e) => {
console.log(`Widget "${e.detail.widget.name}" demoted`)
}, { signal })
const origRemoved = node.onRemoved
node.onRemoved = function () {
controller.abort()
origRemoved?.apply(this, arguments)
}
})
}
})
Cleanup
Use an AbortController to clean up all event listeners when a node is removed.
import { app } from "../../scripts/app.js"
app.registerExtension({
name: "MyExtension.Cleanup",
async nodeCreated(node) {
if (!node.subgraph) return
const controller = new AbortController()
const { signal } = controller
node.subgraph.events.addEventListener("input-added", (e) => {
console.log(`Input added: ${e.detail.input.name}`)
}, { signal })
node.subgraph.events.addEventListener("removing-input", (e) => {
console.log(`Input removing: ${e.detail.input.name}`)
}, { signal })
const origRemoved = node.onRemoved
node.onRemoved = function () {
controller.abort()
origRemoved?.apply(this, arguments)
}
}
})
onRemoved can also fire during subgraph conversion, not just deletion. Guard teardown logic if you need to preserve state across restructuring.
See Also