Reuse a flow across tabs, then stop trusting the first green light
A flow that only ever lived on one tab does not need an architecture. The moment the same logic has to be called from somewhere else, or run as five separate copies with five separate settings, you need a real answer to three questions: what calls what, whose state is whose, and what a shared file is allowed to carry once it leaves your machine. This part covers Links, Subflow instances, environment variables, the Library, and safe import and export — then turns to a second, related discipline. A green status, a payload in Debug, and a fired Complete event all look like proof that something worked. Each of them proves something much narrower than that, and the second half of this part is about learning exactly how narrow.
editorTheme.projects.enabled in Add-on 22.0.1. Projects stays off until you deliberately turn it onMaking a flow reusable and knowing whether it worked are not the same skill
Once the same logic has been copied across three tabs, a fix will usually reach only two of them. Node-RED gives you five different tools for the reuse problem — Link nodes, Subflows, the Library, import/export, and Projects — and each one solves a different slice of it. Treat them as one big “reuse” bucket and you end up trusting a Link Out that was never going to return, or handing someone an export that still carries your real broker address inside a Comment nobody read.
| Tool | Best suited to | What it does not guarantee |
|---|---|---|
| Link In / Link Out | One-way virtual wiring across tabs | It is not a function call. It does not return automatically |
| Link Call + return | A time-bounded request/response exchange | A timeout neither cancels the work nor suppresses a late return |
| Subflow | Reusing one internal graph through several instances | It does not share one node context across instances, and cannot recursively contain itself |
| Library | Saving flow or Function content for reuse in the editor | It is neither deployment history nor a remote Git workflow |
| Import/export | Moving a snapshot of flow JSON | It is not evidence of trust, and it does not remove environment-specific identifiers for you |
| Projects | Git-backed projects, branches, and version control | Disabled by default in this add-on. Its standard workflow tracks an encrypted credentials file, but that is still not a complete backup |
The second half of this part is a separate mistake, and a more common one. Four Node-RED nodes exist to tell you what actually happened — Debug, Catch, Status, and Complete — and each of them answers a narrower question than it looks like it does.
msg.payload lands in Debug in exactly the shape you expected. That confirms one node received one message correctly. It says nothing about the three nodes wired after it.A one-way wire, and the one Link Out that closes a loop
Link In and a Link Out in its normal, “send to all” mode create virtual wiring between flow tabs. When a Link Out receives a message it forwards it to every connected Link In. That is message delivery, not a call with a return value. Because the wiring is virtual rather than a drawn wire, it is usually visible only when you select the Link node — which is exactly why each one needs a name that identifies both the action and the contract, something like “Send to / pure-data normalization,” never just “Next.” The Node-RED 5.0.2 node documentation states explicitly that Links cannot connect into or out of a Subflow.
Link Call is the mode that waits for an answer. It sends a message to a specified Link In, and the callee path must end at a Link Out configured in return mode. Using internal call-origin data, that return sends the message back to the original Link Call, and only then does the Link Call emit it from its own output. An ordinary “send to all” Link Out does not complete a Link Call — it is a different node mode entirely, and confusing the two is the single most common reason a call hangs.
| Node / mode | What happens when a message arrives | How failure appears |
|---|---|---|
| Link Out: send to all | Forwards the message to connected Link In nodes, then finishes | There is no call-waiting or return contract at all |
| Link In | Receives a message over a virtual wire and emits it from its output | Nodes further along the path must still report their own errors |
| Link Call: static | Calls one fixed Link In and waits for a return | No response before the timeout raises a catchable timeout error |
| Link Call: dynamic | Uses msg.target to resolve a Link In by ID, then by name on the same tab, then by name among all regular flows | An ambiguous resolution level, a missing target, or an invalid target raises an error |
| Link Out: return | Returns a response to the caller, but only inside a valid call chain | If the path did not originate from a Link Call, there is no caller to return to, and the node warns |
sequenceDiagram participant LC as "Call / offline doubling (static Link Call)" participant LI as "Callee / offline doubling (Link In)" participant FN as "Validate and double (Function)" participant LO as "Return / offline doubling (Link Out, return mode)" LC->>LI: msg.request = requestId + value LI->>FN: forward the message FN->>LO: msg.response = requestId + doubled LO-->>LC: return, closing this one call
Build this exact chain once, offline, and the difference between a wire and a return stops being theoretical.
-
Step 1
Create the manual request entry point
On an isolated tab, add an Inject node named Manual call LC-001. Set the payload type to number, value
4. Under properties add the stringmsg.requestId = "LC-001", setoncetofalse, and leave repeat and crontab blank. Connect it to a Change node named Build request contract that uses JSONata to setmsg.requestto{"requestId": requestId, "value": payload}, then deletesmsg.payload. -
Step 2
Configure a static Link Call
Add a Link Call named Call / offline doubling. Choose the static call type, set the timeout to 2 seconds, and explicitly select the Link In from the next step as the target — do not use
msg.targethere. The wiring must read: “Manual call LC-001” → “Build request contract” → “Call / offline doubling.” -
Step 3
Create the named callee path
On a different, regular flow tab, add a Link In named Callee / offline doubling. Connect it to a pure Function named Validate and double, then to a Link Out named Return / offline doubling with its mode set to return. The Function reads no context and performs no external I/O:
const request = msg.request;
if (!request || typeof request.requestId !== "string"
|| typeof request.value !== "number" || !Number.isFinite(request.value)) {
node.error("INVALID_REQUEST", msg);
return null;
}
msg.response = {
requestId: request.requestId,
doubled: request.value * 2
};
return msg; -
Step 4
Observe the normal response and the timeout separately
Connect the Link Call's output to a Debug node named Inspect response that shows only
msg.response. Add a Catch node named Catch Link Call timeout, scope it only to “Call / offline doubling,” and connect it to a Debug node showing onlymsg.error. Press Inject once for the normal test: the only projection you should see is{"requestId":"LC-001","doubled":8}, and Catch receives nothing. To test the timeout, temporarily disconnect only the wire from “Validate and double” to the return Link Out. After 2 seconds, Catch reportsmsg.error.messageastimeout, with source typelink calland source name “Call / offline doubling,” and the normal Debug receives nothing. Reconnect the return wire immediately after the test. -
Step 5
Verify, clean up, and export
Confirm the complete path reads exactly: manual Inject → Change/request contract → static Link Call → named Link In → pure transform → return Link Out → limited Debug. It contains no Action, HTTP, MQTT, file, scheduling, or credentials node. Clear the Debug messages, make the smallest possible export, and review it as text before you do anything else with it.
| Case | Result |
|---|---|
| Input | msg.payload = 4; msg.requestId = "LC-001" |
| Request built | { requestId: "LC-001", value: 4 } |
| Normal Debug | {"requestId":"LC-001","doubled":8} |
| Timeout Catch | error.message = "timeout"; the normal Debug receives no message |
| External I/O | None. Link Call timeout: 2 seconds |
Late returns are not cancelled
In Node-RED 5.0.2, the default Link Call timeout is 30 seconds — the walkthrough above shortens it to 2 seconds purely to make waiting bearable. When the timeout expires, the pending request is removed and the error mechanism fires, which is what lets a Catch node receive it. A timeout neither cancels the work nor suppresses a late return. If the return arrives after the timeout, the pending record is already gone, so the Link Call simply emits the message as ordinary downstream output. Both your timeout-compensation logic and your ordinary downstream logic must therefore assume that either path, or both, can run. Never build on the assumption that only one of them fires.
You order a takeaway, wait twenty minutes with no sign of it, and cook something else and eat. Five minutes later the original delivery knocks anyway. Nobody cancelled it — it was already on its way when you gave up. If your kitchen has no way of noticing that dinner already happened, you end up with two meals on the counter and no idea which one to trust. That is exactly the shape of a late Link Call return: the timeout gave up waiting, but the work itself kept going.
requestId. The basic exercise above has no side effects, so nothing above needs a gate. Before you add any, implement a single-flight check. Ahead of the call, write msg.request.requestId into the bounded key flow.activeLinkRequestId. On the timeout Catch path, clear that key before you display the error. Downstream of the Link Call, pass every message through the Function below first: a normal return matches and clears the active ID; a late return that arrives after the timeout finds the ID already cleared and is dropped before it can reach anything with a side effect. Concurrent requests must not share one key — use a pending map with a maximum size and expiry cleanup instead.const activeId = flow.get("activeLinkRequestId");
const responseId = msg.response && msg.response.requestId;
if (typeof responseId !== "string" || responseId !== activeId) {
return null;
}
flow.set("activeLinkRequestId", undefined);
return msg;flowchart TD A["A return arrives carrying
msg.response.requestId"] --> B{"Does it match
flow.activeLinkRequestId?"} B -->|"yes, it matches"| C["Clear the active ID.
Pass the message on"] B -->|"no match, already cleared"| D["Return null.
The late return is dropped here"]
Constrain dynamic targets
msg.target must never come directly from untrusted input. Version 5.0.2 resolves a dynamic target in a fixed order, and the further down that order it has to search, the more room there is for an ambiguous match.
| Attempt, in order | What happens if it fails |
|---|---|
| 1. Exact Link In ID | Falls through to attempt 2 |
| 2. A unique name on the same tab | Falls through to attempt 3 |
| 3. A unique name among all regular flows | A duplicate name anywhere raises an error. A Link Call can never reach a Link In inside a Subflow |
If your target set is fixed, prefer a static target outright, or map the operation through an explicit allowlist in the node before the Link Call — never let a caller-supplied string become the target string directly.
// Offline Function: creates only fixed target aliases; performs no external I/O
const allowed = { double: "Offline doubling entry", label: "Offline labeling entry" };
if (!Object.hasOwn(allowed, msg.operation)) {
node.error("UNKNOWN_OPERATION", msg);
return;
}
msg.target = allowed[msg.operation];
return msg;Every instance gets its own node context. Nothing else is automatic
A Subflow is a reusable internal graph of nodes. Each time you drag it onto a flow you create an instance, and a change to the Subflow's definition affects every instance at once — which is why a Subflow suits “the same algorithm with different non-secret parameters,” not copies you actually want to diverge over time. The nodes inside each instance are distinct runtime instances, and their node context should be treated as isolated by default. Deliberately reading or writing the parent flow's context from inside a Subflow crosses that boundary on purpose, and it needs to be documented as such.
Subflow properties become environment values for a given instance, which is where non-sensitive parameters like DISPLAY_LABEL or MAX_COUNT belong. A Function retrieves one with env.get("DISPLAY_LABEL"), and a typed input field can select the environment-variable type directly. The runtime also exposes instance information such as NR_SUBFLOW_ID, NR_SUBFLOW_NAME, and NR_SUBFLOW_PATH, which help you tell instances apart while diagnosing — but IDs and paths like these must not turn up in a public example or a shared record.
// Pure-data Function inside a Subflow
const label = env.get("DISPLAY_LABEL") || "Unnamed instance";
const current = context.get("seen") || 0;
context.set("seen", current + 1);
msg.result = { label, sequence: current + 1 };
return msg;Run two instances of that Function and each instance's seen count starts at 1, independently, because node context is scoped per instance. If a requirement genuinely calls for shared statistics, document the shared state's lifecycle first, then deliberately choose flow or global context — never reach for one simply because it happens to be reachable. A Subflow can read its parent's context or environment through $parent., but doing so increases coupling and belongs in the interface documentation, not buried in a Function. Even when a Context store uses localfilesystem, an assignment does not necessarily become durable the instant you make it. Context, in every case, is not a secret store either.
| Data | Recommended location | Reason |
|---|---|---|
| A temporary count for one internal node | Node context | Naturally isolated by instance |
| An instance's display label or limit | Subflow property | Each instance can set it explicitly |
| Non-secret state shared by several nodes in the parent flow | Explicit parent flow context | Cross-boundary sharing must be documented in the contract |
| A token, password, or private key | Neither a property nor Context | Use managed credentials or the platform's own secret mechanism |
flowchart TD P["Parent flow context"] A1["Instance 1: Function node"] --> C1["Instance 1's own node context
seen = 1, then 2, then 3 ..."] A2["Instance 2: Function node"] --> C2["Instance 2's own node context
seen = 1, then 2, then 3 ..."] A1 -.->|"only if the code says $parent."| P A2 -.->|"only if the code says $parent."| P
$parent. draws no dashed arrow at all.A Subflow instance is a franchise location. Every location cooks from the same recipe — the Subflow definition — but keeps its own till roll. Location 1's till does not know what location 2 rang up today, and it should not need to. The one thing every location can do, if someone deliberately picks up the phone, is call head office and read a number off the shared ledger. That call is $parent., and it only happens because someone chose to dial it, not because the tills are secretly wired together.
Never let a Subflow contain itself
Do not place a Subflow instance directly inside its own definition, and do not create a cycle where A contains B and B contains A. This is not ordinary function recursion with a base case that eventually stops it — it builds an architecture that cannot be expanded safely and creates a real resource risk. For anything that needs repeated processing, use bounded message iteration or a finite-state design instead, and test its termination condition explicitly before you trust it.
Portable is not the same thing as safe to hand to someone else
In everything above, environment variables are for non-sensitive, replaceable configuration only — display text, a batch limit, that kind of value. Do not put tokens, passwords, private keys, or connection credentials into flow JSON, Subflow properties, Function code, Debug output, or Context. Environment-variable names and values can themselves be exposed through exports, diagnostics, or the runtime environment. Simply “using env” does not make a secret secure; it just moves where the secret is sitting in plain text.
The Library is a reusable store inside the editor: save flow snippets or Function code and retrieve them later. Import/export serializes the current selection to JSON, or brings JSON into the editor, which suits an explicit, one-time transfer. Projects gives you a complete Git-backed working directory with a repository, branches, remotes, and a Git identity. None of the three replaces a managed backup, and none of them scrubs secrets for you.
| Aspect | Library | Import/export | Projects |
|---|---|---|---|
| Unit | A reusable snippet or Function | One JSON snapshot | Files inside a Git-backed project |
| History | Must not be treated as complete change history | You store and compare snapshots yourself | Git commits and branches manage history |
| Dependencies | Nodes and configuration still need review after retrieval | Missing node types and configuration still need review on import | Package and runtime differences still need managing |
| Secrets | Scrub before saving | Scrub before sharing | The standard workflow commits an encrypted project credentials file; the repository must stay private, and the project credential secret must be stored separately |
editorTheme.projects.enabled defaults to false. Do not follow an upstream Projects tutorial on the assumption that the interface is already there, and do not edit an add-on-managed settings file directly. If your organization approves turning it on: create a recoverable backup, record the current state, stop the add-on, make the change through a method the add-on actually supports, then restart and verify. Follow the pinned add-on documentation and your own change-control process for the exact steps — there is no shortcut here that skips the backup.Once Projects is enabled, access to Git remotes and Git credentials becomes another security boundary of its own. The standard Projects workflow in Node-RED 5.0.2 adds an encrypted project credentials file to version control. That is expected behavior, and it does not make the file safe to publish — access to the project repository and its full history both need to stay restricted. Every project has its own project credential secret. Back it up securely, separately from the repository, or the encrypted credentials become unrecoverable the day you lose it.
The add-on's own credential_secret is ignored in Projects mode and cannot decrypt project credentials. The project repository, the project credential secret, your Git access credentials, and a complete add-on backup are four different things that each serve a different purpose. Git history is not a complete backup of the runtime environment, and an add-on backup does not replace auditable version history either.
The pre-sharing scrub checklist
Flow JSON is readable and comparable, which is exactly what makes it easy to carry environment details somewhere they should not go. Start an export from the smallest possible selection; do not select an entire workspace for convenience. If a snippet depends on a configuration node, document the type of configuration it requires rather than keeping a real production ID inside it.
- No credentials block, token, password, private key, cookie, Authorization value, or webhook identifier.
- No real URL, hostname, IP address, Home Assistant server/entity/device/area ID, MQTT client ID, or topic.
- Every configuration-node reference identified; in the target environment, reselect an approved configuration node through the editor rather than editing a JSON ID by hand.
- No Inject set to run at startup, no schedule, listener, external connection, file write, or action path left live; if the snippet keeps an operational node, disable it before export and document why.
- Every Function, Template, Change, JSONata expression, Comment, node name, and flow description reviewed character by character.
- Only required wires remain; Link targets, Subflow definitions, and instance properties are complete and contain no recursion.
- The JSON inspected as plain text, then imported into a blank, isolated tab, with node count, types, disabled state, and configuration dependencies verified there.
msg.payload.DISPLAY_LABEL, non-secret text.Handing over a flow export should feel like handing someone a shopping list. It should not feel like handing over your diary. The logic — add these two numbers, wait for this event — is the list. Your real hostnames, entity IDs, and the comment where you typed a password to remind yourself are diary pages, and they end up in the same folder unless somebody deliberately pulls them out first.
Before importing unknown JSON, read type, wires, d, the Inject node's once setting, and every configuration field in a plain text viewer first. If a node type is reported missing, do not install a package just to clear the warning — verify the source, the pinned version, and whether you actually need it before you add anything.
The least data, for the shortest time, at the lowest frequency
A Debug node can show a selected message property, the complete message, or the result of a JSONata expression in the Debug sidebar. It can also write output to the runtime log, or show a short value as the node's own status. Its default is msg.payload. The sidebar's structured view makes objects and arrays easy to expand and can jump from source information straight to the node on the canvas — convenient, but that convenience is not a reason to leave it outputting everything indefinitely.
A complete msg can carry locations, notification text, Home Assistant entity data, live HTTP objects, or other environment-specific information you never meant to expose. Content sent to the runtime log leaves the sidebar's temporary context behind and may land in a centralized collection and retention system with its own rules. Stack traces can likewise reveal file paths or sensitive content, so sanitize them before you share one with anybody.
| Practice | Purpose | Cost or risk |
|---|---|---|
| One property to the sidebar | Confirm its type and a local result | You still have to confirm that property itself is not sensitive |
| Complete msg to the sidebar | Briefly explore an unknown shape | Higher serialization, copying, and rendering cost, with greater exposure |
| JSONata projection to the sidebar | Select only allowlisted fields | The expression itself needs testing against missing values and errors |
| Output to the runtime log | Limited diagnostics when the editor is not open | Retention periods, authorized readers, and centralized-log boundaries differ from the editor |
| Node status display | Show a very short summary or count | Length-limited, and no substitute for structured validation |
// Offline Function: builds a minimal diagnostic projection, no full-input copy
msg.testResult = {
caseName: String(msg.caseName || "Unnamed case"),
payloadType: typeof msg.payload,
hasTopic: Object.hasOwn(msg, "topic")
};
return msg;Three different questions, and none of them is Debug's question
A green status beneath a node does not mean the message it just processed succeeded. An expected payload in Debug does not mean every downstream node has finished. A Complete event does not mean all downstream work has finished either. These four core nodes observe four different events, and conflating any two of them is how a false signal of success gets into a change record.
| Node | What it observes | Output focus | What it cannot establish |
|---|---|---|---|
| Debug | An ordinary msg actually received by the node | A selected property, the complete msg, or a JSONata result | No message does not necessarily mean an upstream error occurred |
| Catch | A catchable error reported by a node inside its scope | msg.error plus source information | It does not represent every failure, rejection, or status change |
| Status | A status event emitted by a node inside its scope | msg.status; it never creates a payload | Status text is not the result of processing a message |
| Complete | The selected node telling the runtime it has finished processing a message | Triggers a separate flow path | Not proof that all downstream work is complete, and not every node supports it |
flowchart TD F["Function node:
Generate a finite diagnostic event"] F -->|"ordinary return msg"| D["Debug reads msg.payload"] F -->|"node.error(msg) on failure"| C["Catch reads msg.error"] F -->|"node.status() call"| S["Status reads msg.status"] F -->|"node.done() call"| X["Complete reads msg.complete"]
When the runtime creates a Catch message it adds msg.error. If the original message already had an error property, that existing value moves to msg._error instead of being overwritten silently. Business data should never appropriate the reserved meaning of msg.error for its own use. A Status node explicitly creates no payload; downstream nodes need to read msg.status rather than carry over payload assumptions from an ordinary data path.
Debug is a text that says “order placed.” Status is the shop's own “we are open” sign in the window. Catch is the phone ringing to say the card was declined. Complete is the till printing a receipt. A printed receipt tells you the till finished ringing up that one transaction — it does not tell you whether the item ever left the warehouse, and it certainly does not tell you the sign in the window is telling the truth right now.
Catch: only catchable errors
A Catch node receives an error only when a node reports it through the runtime's error mechanism while processing a message. Inside a Function node, call node.error(message, msg) with the original message to make an error catchable. Calling only node.error(message), without the message, does not associate the error with that msg for a Catch node to pick up. As a rule, report a validation failure explicitly and stop processing there — do not emit a success result and an error together.
// Pure-data validation: no network, file, or Home Assistant operation
if (!Number.isFinite(msg.payload)) {
node.error("INVALID_NUMBER", msg);
return;
}
msg.testResult = msg.payload * 2;
return msg;By default, Catch captures errors from nodes on the same tab. You can instead select specific nodes, or capture only errors an already-targeted Catch has not handled. If an error matches several Catch nodes, every match receives it — which means several error-handling paths can duplicate the same work if you are not careful. An error inside a Subflow is handled first by a Catch inside that Subflow, and only propagates out to the tab holding the instance if nothing internal matches. A third-party node failure, a connection condition, an empty output, or a business-level rejection is not automatically a catchable error — check the node's own contract and verify its behavior rather than assuming.
Status: what nodes actively publish
A Status node receives status messages published by nodes on the same workspace tab by default, or you can select individual nodes instead. Its output carries msg.status.text plus the source type, id, and name; it never creates a payload. A node showing “connected,” “waiting,” or a color change is reporting only the status that node chose to publish — not proof that any particular business message succeeded.
| Observation | Check first | Reason |
|---|---|---|
| Function validation rejects input | Catch | node.error(..., msg) is what creates a catchable error |
| A node shows connecting/connected | Status | This is node status, and it is not necessarily tied to one msg |
| An ordinary data result is wrong | Limited Debug + invariants | No error or status event may exist at all in this case |
| A selected node finishes processing | Complete | The node has to support the completion API for this to apply |
d: true. Do not enable every observer in a live flow at once merely to watch events happen; read the isolation procedure in the testing section before you turn any of them on.Complete: one node's own completion, not the whole path's
A Complete node triggers when a selected node tells the runtime, “I have finished processing this message.” Node-RED 1.0 introduced this through the node completion API, and the node's own implementation has to call done when its work — synchronous or asynchronous — actually finishes. Not every node supports it, so the absence of a Complete event does not by itself prove failure.
Complete requires you to select the nodes you want to watch, unlike Catch's whole-tab default. It is useful for a node with no output port that still implements completion, or for turning the end of one node's processing into a separate internal control path. It proves only that “the selected node declared completion.” It does not prove every downstream message that node previously sent has finished processing, and it does not prove an external system has completed its part either.
| Statement | Can Complete alone establish it? | Qualification |
|---|---|---|
| The selected node called the completion API | Yes | The node's own implementation defines the exact semantics |
| Downstream received the selected node's output | Not necessarily | Completion and downstream execution have different scopes |
| Every node in the entire flow has finished | No | There is no automatic whole-graph join semantic |
| The external service has permanently completed the work | No | The protocol needs an explicit acknowledgement or a verification step |
| No Complete event means the node failed | No | The node may simply not implement completion at all |
A Function node's synchronous path can use return msg directly. Its asynchronous mode normally calls node.send(msg) and then node.done() once the work has truly ended. Function nodes also carry On Start / On Stop lifecycle hooks for initializing or cleaning up resources at deployment and shutdown boundaries — and that lifecycle code needs its own error, timeout, and cleanup strategy too, not treatment as unbounded background work. This part deliberately skips timers and external-I/O examples so an asynchronous demonstration cannot leave residual work behind for you to clean up later.
Run the reproducible example
The downloadable example 11-debug-catch-status.json contains exactly one Complete node, named “Observe Function completion,” scoped only to the core Function node “Generate a finite diagnostic event.” Complete, Catch, and Status all start disabled with d: true. Complete connects to its own Debug node, “Inspect complete,” which projects only msg.complete and stays entirely separate from the two Debug nodes that project msg.error and msg.status.
-
Step 1
Read it as text first
Confirm it contains 12 nodes, that all three Inject nodes use
once=falsewith repeat and crontab left blank, and that the Function node has no lifecycle hooks, modules, timers, or I/O. -
Step 2
Enable only Complete, and click once
Import an isolated copy, enable Complete only, and leave Catch and Status disabled. Click “Manual test: normal output” once. Do not replace it with a startup or scheduled Inject.
-
Step 3
Confirm the two exact projections
The ordinary Debug node receives exactly
{"caseName":"normal","doubled":8}. The Complete Debug node receives exactly{"source":{"id":"b000000000000005","type":"function","name":"Generate a finite diagnostic event"}}. That second value is a projection ofmsg.complete, not a complete message, and it does not mean the downstream Debug node has itself finished. -
Step 4
Restore Complete, then test the other two, one at a time
Immediately set Complete back to
d: trueafter the test. Enable only the corresponding observer for each of the remaining paths. The Catch Inject makes the error Debug receive messageTEST_ERRORwith a source pointing to the same Function insidemsg.error. The Status Inject makes the status Debug receive fillblue, shapedot, textTEST_STATUS, with a source pointing to that Function insidemsg.status. Neither path produces any ordinary payload output.
msg.error, and Status keeps reading only msg.status. Do not enable every operational observer at once, and do not treat completion as end-to-end acknowledgement of anything beyond the one node you selected.Mock first, then Context, then a Subflow or Link boundary
Flow testing does not need to start with a live installation. Fix the input shape with a mock message first, then gradually introduce Context, Subflow, or Link boundaries one at a time. A mock is not meant to reproduce every piece of environmental data — it proves the logic with the fewest fields necessary. Unknown fields, missing values, boundary values, and the wrong type each need to become their own explicit test case, not an afterthought.
| Stage | Test | Pass condition |
|---|---|---|
| 1. Pure transformation | Manual Inject → pure Function/Change → limited Debug | Normal, boundary, and invalid types all behave as expected |
| 2. Error contract | Targeted Catch → error-projection Debug | Each rejected case enters only its expected error path |
| 3. Status/Complete | Targeted Status and Complete nodes, recorded separately | Neither status nor node completion is treated as end-to-end success |
| 4. Architectural boundaries | Two Subflow instances, or a Link Call timeout | State isolation, return, and timeout contracts all hold |
| 5. Pre-integration review | External I/O kept disabled; review config, targets, recovery | Your change process separately approves entry into the integration environment |
| 6. Regression | Core cases + the current fix's case + smoke cases for adjacent flows | No unexpected message shapes, errors, or side effects appear |
Invariants, not just payload comparisons
- Input
msg.topic,msg._msgid, and other designated metadata are not overwritten unexpectedly. - An error case is never also sent to the success output.
- Each input produces no more outputs than the contract permits, and is not processed twice because two Catch nodes both matched.
- Context keys and their contents stay bounded; state after a redeploy matches the design, not a leftover from before.
- Every timeout, retry, and buffer has an upper bound; the mocks used in this part create no queues, timers, or external I/O of their own.
At minimum, test a missing payload, null, a string in place of a number, NaN or infinite values, values outside the permitted range, extra fields, duplicate messages, and messages arriving out of order. For Link Call, also test a missing return. For a Subflow, interleave two instances. For Catch, test a pre-existing msg.error. For Status, test downstream code that misreads the payload. For Complete, test an unselected node and a node that does not support completion at all.
Do not use a visual impression of the Debug sidebar as your only evidence. Put case tables, expected fields, and result summaries — with no environment data — into the change record instead. Real secrets, complete messages, stack traces, and runtime logs do not belong in a general-purpose ticket.
Deployment scope and regression scope
Node-RED 5.0.2 gives you three deployment scopes: Full, Modified Flows, and Modified Nodes. Full restarts every node. Modified Flows restarts any flow containing a changed node. Modified Nodes restarts only the nodes actually modified. A smaller scope reduces unrelated restarts, but it does not automatically guarantee safety on its own. Work out first whether the change touches Subflow instances, the users of a configuration node, Context, or any other shared path, and only then choose the smallest scope that is genuinely sufficient.
Before every deployment, record a recoverable version and inspect startup Inject nodes, schedules, listeners, and any node with an external side effect. After deployment, run one pure-data smoke case first, then the regression tests for the current fix and its adjacent flows. If a configuration-node or shared-Subflow change affects several flows, do not shrink the scope you actually deploy just to claim the smallest label.
The failures that actually happen, matched to the fix
| Symptom | Likely cause | What to do |
|---|---|---|
| A Link Call waits and then reports a timeout | The target is not a Link In, or a path ends at a plain “send to all” Link Out instead of one set to return | Confirm every success and rejection path ends at a return-mode Link Out. Scope a Catch node to the call to observe it; do not just lengthen the timeout to hide a missing path |
| A return Link Out warns that no return source exists | The path was entered through a regular Link or an Inject, not through a Link Call | Keep one-way entry points and call entry points separate, so they never share a return that is only valid inside a call chain |
| Two Subflow instances affect each other | The implementation reads or writes flow/global or $parent. context instead of node context inside the instance |
Send manual input in A→B→A order and verify each instance's count independently |
| An unknown configuration node or red triangle appears after import | The exported file referenced a configuration node ID that does not exist in this environment | Do not edit the reference ID by hand. Open the node that uses it, confirm its type and fields, then reselect an approved configuration node in the target editor. Leave it disabled if none is available |
| Projects is not available in the sidebar | It is disabled by default in this add-on | Not a browser problem. Do not edit settings directly — follow the approved backup, shutdown, controlled-change, restart, and recovery process |
| An export looks free of credentials but still cannot be shared | Ordinary fields, Comments, Function strings, Subflow properties, or configuration nodes still carry environment identifiers | Have a second person review the plain text. Do not rely solely on the export dialog's scope summary |
| Catch does not receive a Function's error | The Function called node.error without the message, or the Catch's scope does not include it |
Confirm the code reads node.error("category", msg) and stops emitting output, and that the Catch's scope actually includes that Function |
| The same error gets handled twice | It matches both a targeted Catch and a second, broader Catch | Make each Catch's responsibility mutually exclusive, or use “not already caught” mode as the last line of defense |
| The payload downstream of Status is undefined | A Status node does not create a payload at all | Read msg.status.text and msg.status.source instead, and keep the status path separate from the ordinary data path |
| Complete fires, but later work has not finished | This is a scope mistake, not a bug | Complete means only that the selected node declared completion, not a downstream barrier. Define a final confirmation signal or a bounded join contract if you need end-to-end proof |
The ones that come up again and again
What should I do first if a Projects repository accidentally becomes public?
Can I reuse a requestId immediately after a timeout?
Which regression is easiest to miss after changing a Subflow definition?
Should environment variables ever hold tokens?
Does enabling Projects give me a backup?
credential_secret is ignored in Projects mode. A complete add-on backup and Git version history keep serving separate purposes.Can I edit a JSON ID directly when a configuration-node reference breaks after import?
Does Complete mean the entire flow has finished?
Why not just leave complete-message Debug output enabled all the time?
Which regression tests should I run after fixing a Function?
How do I prevent an error loop if the Catch compensation path itself fails?
Status changes repeatedly within a short period. What should the test examine?
Can I deploy the example immediately after downloading and importing it?
d: true; any activation and deployment requires separate approval.Where to go from here
You can now build a call that actually returns, and you no longer take a green light on faith.
Part 2 closes here. Part 3 opens with what happens when a flow stops talking only to itself — HTTP endpoints and APIs, the boundary where something outside the Node-RED editor can reach in, and the security choices that boundary forces on you from the first request.
Open the full guidePart 9 of the WoowTech Complete Node-RED Guide series on the Apporo blog.
Adapted from the WoowTech Complete Node-RED Guide, produced by WoowTech and released under CC BY 4.0. This adaptation is published by Apporo under the same licence.
Light · Air · Water · Control · apporo