Node-RED's visual programming model has a simple contract: if you can see the wires, you understand the flow. But three built-in nodes break this contract — catch, complete, and status. They listen for events without wires, creating invisible connections that don't appear in the flow diagram.
I've built a solution into the @bonsae/nrg framework that makes these connections explicit through optional output ports.
The Problem
Consider a typical error handling pattern:
[inject] → [http request] → [process] → [output]
[catch (scoped to http request)] → [error handler]
The catch node monitors the http request node, but there's no wire between them. Someone reading the flow sees two disconnected chains. They have to open the catch node's config, read the scope array, and mentally trace which nodes it monitors. The same problem exists for complete and status nodes.
This creates three concrete issues:
-
You can't trace errors visually. When a flow fails, you have to hunt for catch nodes and check their scope configs to find the error path. With 50 nodes and 10 catch nodes on a tab, good luck.
-
Copy/paste breaks. Catch and complete nodes store scope as an array of node IDs. Copy a group of nodes to another tab — the IDs change, the scopes don't update, the error handling silently disconnects.
-
Code review is blind. In a
flow.jsondiff, a catch node's scope array is just a list of hex IDs. You can't tell what changed without cross-referencing every ID. Wires in thewiresarray are self-documenting — they connect node A to node B, period.
The Return Code Pattern
If you've written C, you know there's no try/catch. Functions signal errors through return values — the caller checks the result and decides what to do:
int result = do_something();
if (result < 0) {
handle_error(result);
return;
}
proceed_with(result);
Every error path is explicit in the code. There's no hidden exception handler somewhere else in the program catching errors on your behalf. The control flow is visible, linear, and traceable.
Node-RED's catch node is the opposite — it's exception handling at a distance. The error jumps from the failing node to a catch node somewhere else on the canvas with no visible connection. You look at a node with one output and assume success is the only path. The failure path exists — it's just invisible.
Emit ports bring the C philosophy to Node-RED: errors travel through wires, just like data. The node has explicit outputs for success, failure, and completion. Every path is visible in the same visual language.
The Solution: Opt-In Emit Ports
With NRG, node developers add three optional boolean properties to their config schema:
const ConfigsSchema = defineSchema({
name: SchemaType.String({ default: "" }),
// ... node-specific config
emitError: SchemaType.Boolean({ default: false }),
emitComplete: SchemaType.Boolean({ default: false }),
emitStatus: SchemaType.Boolean({ default: false }),
}, { $id: "my-node:config" });
The framework handles everything else:
- Toggle switches appear in the node editor only if you declared the props in the Config schema given to the node.
- Output ports are dynamically added/removed when toggles change
- Port labels show "Error", "Complete", "Status" on hover
- Messages are routed through wires — visible, traceable, debuggable
- Backward compatible — catch/complete/status nodes still work alongside
The following image shows how ports can be enabled when available (declared in the config schema).
How Each Port Works
Error Port
Fires on both explicit this.error() calls and uncaught exceptions in the input() handler:
{
"payload": "original data",
"error": {
"message": "SOQL query failed: MALFORMED_QUERY",
"source": { "id": "node-abc", "type": "salesforce-soql", "name": "Query" }
}
}
Errors are sent to both the wire and the implicit catch system. Enabling the error port doesn't disable catch nodes — they coexist.
Complete Port
Fires when input() finishes successfully:
{
"payload": "original data",
"complete": {
"source": { "id": "node-abc", "type": "salesforce-soql", "name": "Query" }
}
}
Wire it to trigger cleanup, logging, or the next step in a pipeline.
Status Port
Fires every time the node calls this.status():
{
"status": { "fill": "green", "shape": "dot", "text": "30 records" },
"source": { "id": "node-abc", "type": "salesforce-soql", "name": "Query" }
}
Route status updates through wires to build monitoring dashboards or trigger alerts.
Example: Iterator Node
To see this in action, here's an iterator node built with NRG. It loops over an array, emitting each item individually. The schema opts into all three emit ports:
export const ConfigsSchema = defineSchema({
name: SchemaType.String({ default: "" }),
emitError: SchemaType.Boolean({ default: false }),
emitComplete: SchemaType.Boolean({ default: true }),
emitStatus: SchemaType.Boolean({ default: false }),
}, { $id: "iterator:config" });
The node logic is straightforward — iterate, send each item, update status:
export default class Iterator extends IONode<Config> {
static override readonly type = "iterator";
static override readonly outputs = 1;
static override readonly configSchema = ConfigsSchema;
override async input(msg: any) {
const items = msg.payload;
if (!Array.isArray(items)) {
this.error("msg.payload must be an array", msg);
return;
}
for (let i = 0; i < items.length; i++) {
this.status({ fill: "green", shape: "dot", text: `${i + 1}/${items.length}` });
this.send({ ...msg, payload: items[i], index: i, count: items.length });
}
this.status({ fill: "green", shape: "dot", text: `done: ${items.length} items` });
}
}
The node developer writes zero port management code. No output counting, no array padding, no port index math. The framework detects emitError, emitComplete, and emitStatus in the schema and handles everything.
With all three ports enabled, the flow looks like:
- Item port: 5 messages with
payload: "Apple",payload: "Banana", etc. - Complete port: 1 message after the loop finishes, carrying the original msg plus
complete.source - Error port: fires if
msg.payloadisn't an array, witherror.messageanderror.source - Status port: 6 messages —
1/5,2/5,3/5,4/5,5/5,done: 5 items
Every path is a wire. No catch node. No complete node. No status node. The node's contract is visible on the canvas.
Iterator with bad data
Iterator with good data
Iterator with Complete/Catch/Status built in nodes
As mentioned above, nodes built with @bonsae/nrg remain fully compatible with the core catch, complete, and status nodes — nothing breaks. But look at the traditional example above: four disconnected chains on the canvas, held together by an invisible scope reference. It doesn't look like a flow — it looks like 4 separate flows that happen to share a tab.
Smaller, Self-Contained Flows
Emit ports produce smaller and more portable flow.json files.
Traditional approach — 6 nodes, fragile scope references:
[
{
"id": "iter-1", "type": "iterator", "name": "Process Items",
"wires": [["debug-item"]]
},
{
"id": "catch-1", "type": "catch", "name": "Handle Error",
"scope": ["iter-1"],
"wires": [["error-handler"]]
},
{
"id": "complete-1", "type": "complete", "name": "On Done",
"scope": ["iter-1"],
"wires": [["done-handler"]]
},
{ "id": "debug-item", "type": "debug", "name": "Item" },
{ "id": "error-handler", "type": "debug", "name": "Error" },
{ "id": "done-handler", "type": "debug", "name": "Done" }
]
435 bytes without white spaces
With emit ports — 4 nodes, self-contained:
[
{
"id": "iter-1", "type": "iterator", "name": "Process Items",
"emitError": true, "emitComplete": true, "outputs": 3,
"wires": [["debug-item"], ["done-handler"], ["error-handler"]]
},
{ "id": "debug-item", "type": "debug", "name": "Item" },
{ "id": "error-handler", "type": "debug", "name": "Error" },
{ "id": "done-handler", "type": "debug", "name": "Done" }
]
319 bytes without white spaces
33% fewer nodes. No scope arrays. No fragile ID references. Copy it, import it, version control it — the wires travel with the node.
27% less data Really good to save tokens when using LLMs to create flows
At scale, a flow with 20 nodes each needing error handling goes from 60 nodes to 20. The canvas stays clean, the JSON stays small, and git diff shows meaningful changes instead of scope array shuffling.
Addressing the Counter-Arguments
"Catch-all handles everything in one place"
The catch node's "catch all" mode is convenient — one node monitors every error on a tab. But this is a catch (Exception e) at the top of your program. It masks where errors come from, makes it impossible to handle different failures differently, and silently swallows errors you didn't know about.
In production, you don't want a single handler for everything. You want to retry transient HTTP failures, log validation errors to a different system, and page someone for authentication failures. A catch-all can't distinguish between these — it sees them all as "an error happened."
Emit ports give you per-node, per-error-type handling through standard wiring. The catch node can still serve as a global safety net for truly unexpected errors — but it shouldn't be the primary error handling mechanism.
"Extra ports add visual clutter"
More ports means more wires means more stuff on the canvas. A catch node scoped to five nodes looks "cleaner" than five nodes each with error ports.
But this is the same argument as saying hidden dependencies are cleaner than explicit imports. The canvas has fewer wires — it also has less truth. When a node fails, you look at it and see one output. You assume success is the only path. The error handling exists — you just can't see it without opening the catch node's config and reading the scope array.
With emit ports, a node with 3 outputs tells you immediately: this node has a success path, an error path, and a completion signal. The flow is honest about its complexity. Complexity hidden isn't complexity removed — it's complexity you'll discover at 3am when something breaks and you can't trace why.
"This only works for NRG nodes"
True — emit ports are a framework feature, not a Node-RED core feature. Nodes built without NRG won't have them. But that's the point of a framework: it sets conventions that make nodes better. Node developers who adopt NRG get this for free with three lines of schema. Users who install NRG-built nodes get the option to wire errors explicitly. Everyone else can keep using catch nodes — nothing breaks.
Port Ordering
Ports are always appended in a fixed order: Error → Complete → Status. Toggling a port never shifts existing wires.
Port 0: Data output (always present)
Port 1: Error (if enabled)
Port 2: Complete (if enabled)
Port 3: Status (if enabled)
Built With NRG
This feature will be available in @bonsae/nrg@v0.12.0.
See it in action in @bonsae/node-red-salesforce@v0.2.0 — a Salesforce integration that uses emit ports for SOQL queries, DML operations, Bulk API, streaming, and Apex invocations.
*I'd love feedback on this pattern. Is explicit wiring for errors, completion, and status something you'd use in your flows?




