Explicit Error, Complete & Status Ports — Making Node-RED's Data Flow Honest

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:

  1. 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.

  2. 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.

  3. Code review is blind. In a flow.json diff, 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 the wires array 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.payload isn't an array, with error.message and error.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 errorsbut 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?

Just to note that there many be many non-core nodes that handle events without wires. Event handling is, after all, a common design pattern for JavaScript in general and node.js specifically. Some of these nodes will have been active for years.

In addition, since function nodes can address any loaded node.js package if allowed, they too may create event listeners.

Front-end tooling (Dashboards and UIBUILDER) will also not match the flow-based approach of course since communications happen, at least in part, outside of flows.

Node-RED's flow pattern is a useful tool but Node-RED is, as you know, not restricted to that pattern of operation.

I agree — Node-RED isn't restricted to the flow-based pattern. Dashboard and UIBUILDER nodes legitimately use event handlers because they bridge two different worlds: the Node-RED runtime and a browser. Events are the right model there — you can't draw a wire to a user clicking a button.

But catch, complete, and status aren't event handlers in that sense. They don't bridge two worlds. They route messages between nodes on the same canvas, inside the same runtime, on the same tab. That's not event handling — that's flow control. And flow control is exactly what wires represent.

The confusion is understandable because catch, complete, and status look like event handlers — they react to something happening elsewhere. But what they actually do is receive a message from node A and send it to node B. That's a wire. It's just drawn as a scope array in a config dialog instead.

My argument isn't against event handlers. It's against using the event handler pattern for what is fundamentally a message path between two visible nodes on the same canvas. When both ends are on the canvas and the message stays in the runtime, that connection should be visible.

Yes that's one approach to flow based programming and I would even go as far as saying J. Paul Morrison would back you up on what you say. I think[*] since in his model, nodes/processes are strictly stateless, that even a generalised error handler would represent a form of dependency and therefore state.

I could also argue why isn't there a debug all node that is implicitly wired to all nodes once dragged into with the workspace? Debug and catch - conceptually - aren't part of the semantics of a flow yet they are dealt with differently - one is wired and the other not.[***]

Now that's an inconsistency. Or not. It is consistent with what Node-RED does, therefore QED it's fine. There are no visual contracts nor design guidelines in Node-RED, at least none that are written on stone tablets. Nick has said that and is very clear on that.

I actually like the idea of having explicit third and fourth ports. As you point out, there are programming languages that have other/alternative mechanisms for handling error states[**]. It would be nice to represent those visually in a flow.

At this point, the exec node is the example - it has stdout, stderr and error ports. So another inconsistency in Node-RED? No, that's Node-RED solution - again: Node-RED isn't guided by FBP therefore (as I have learnt) pointing to FBP and saying "But Node-RED isn't doing it like FBP!" isn't an argument - that's clear and fine.

Generally I think we are left with creating our our nodes that have three ports (if that makes sense for us) and using those. After all, there is nothing stopping anyone from having nodes with as many ports as they like.

That's a good thing about Node-RED, it allows me to try that out. If this was a FBP-only tool, then having something like a catch node won't be possible and that would be worse - IMHO. On the other hand, such a tool would be more standardised - i.e., there will never, ever be an exception node. Which is a guarantee for the future. I can't make that guarantee with Node-RED.

In all these discussions we forget that Node-RED is flexible enough to represent FBP concepts and event driven concepts and message passing concepts, all in one UI. Plus it's extendable in many directions - visually or not. It's then up to us to take that flexibility and move in another direction if that is what we so desire.

I'll do my thing over at Erlang-Red land and have been reflecting on how FBP conform to make the whole thing. But that's purely my approach and I also believe that having boundaries and clearly saying such things as "if debug is wired, so should exception" - but then others would say: no debug and exception aren't the same things/concepts, and yes that's right (so please don't argue that point) they aren't the same, else both would be called "exception" or "debug".

But on what level are they the same? Certainly visually they are the same: both are rectangles. One with an input port the other without. What do they do? One peers into the flow (debug) and the other handles unexpected things coming out of the flow (exception). As such, both are situated outside of the flow and aren't necessary for the correct performance of the flow - the flow will work with or without them[***].

[*]: I don't know and I don't want to put any words in his mouth.
[**]: Erlang takes the attitude fail and recover, here the recovery to a normal state is more important than the failing.
[***]: Catch can well be part of the semantics if using exception-driven development which isn't actually a good design pattern and isn't encouraged amongst CS students. Exception-driven development is when try/catch becomes a control statement just as while/if/then/else. Thus exceptions are raised and caught to control what the program does and not warn of error situations or possible unknown states. Self-healing systems raise errors and notify the operator but then handle those errors themselves - this is different to exception-driven development.

P.S. one could even go as far as pointing out that a complete/status node could be debug nodes or that a debug node isn't wired and would also have a "select nodes" visual interface ... that would probably be more consistent than a wired debug node :wink: