Links, Subflows, environment variables, the Library, and import/export
When a flow grows from a single automation into multiple tabs, reuse is no longer just a matter of drawing fewer wires. You must also define clear boundaries for calls, state, configuration references, and portable data. Using the Node-RED 5.0.2 bundled with Add-on 22.0.1 as its baseline, this chapter guides you through designing a traceable architecture that can be shared safely, using only offline, manual examples.
Define the boundaries of reuse first
Once the same logic has been copied three times, a fix will usually reach only two copies. As long wires multiply across tabs, it also becomes harder to tell where data originated. Node-RED provides Links, Subflows, the Library, import/export, and Projects, but each solves a different problem. Treating them as a single kind of “reuse” obscures call-waiting behavior, per-instance state, configuration-node references, and responsibility for version control.
| 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 and does not return automatically |
| Link Call + return | A time-bounded request/response exchange | A timeout neither cancels work nor suppresses a late return |
| Subflow | Reuse one internal graph through multiple instances | It does not share a single node context, and cannot recursively contain itself |
| Library | Save flow or Function content for reuse in the editor | It is neither deployment history nor a remote Git workflow |
| Import/export | Move a snapshot of flow JSON | It is not evidence of trust and does not automatically remove environment-specific identifiers |
| Projects | Git-backed projects, branches, and version-control work | It is disabled by default in this Add-on; the standard workflow tracks an encrypted project credentials file, but still does not provide a complete backup |
The examples in this chapter manipulate only strings and numbers within messages. They do not use the network, files, Home Assistant actions, or any other external I/O. A Comment node can document an interface on the canvas; a Junction is only a wire-routing intersection in the editor and cannot serve as a Link, call, or reusable component. If msg and Context are not yet familiar, first revisit Chapter 5, the message model, and Chapter 7, Context.
Interface, state, and configuration references are three separate axes
Every reusable component should document at least three things: its input/output contract, state ownership, and external configuration dependencies. The input contract might define the accepted msg.payload type and error behavior. State ownership determines whether two instances affect each other. Configuration dependencies identify whether nodes in the flow refer to a Home Assistant Server, an MQTT broker, or another configuration node.
| Contract surface | Offline example | Validation question |
|---|---|---|
| Input | msg.payload must be a finite number | Should strings, null, and missing values be rejected or converted? |
| Output | On success, output the same msg and add only msg.result | Does it overwrite any field that the caller needs? |
| Error | Report a catchable error without putting the complete input in the error string | Can the caller distinguish a validation failure from a timeout? |
| State | Store the count in node context within the instance | Should two instances maintain separate counts? |
| Configuration | Supply a non-secret label through an instance property | Is there a safe default when the property is missing? |
A configuration node is not an ordinary message-processing node. Other nodes refer to it by ID in JSON. When selected nodes are exported, the editor may include their required configuration nodes or leave references that cannot be resolved in the target environment. Do not edit JSON IDs by hand to make them “match” production, and do not commit real server names, broker details, URLs, or credential values to a file intended for sharing. Instead, import into an isolated copy, open each dependent node, and use the editor to select an approved configuration node from the target environment.
Build one offline Link Call from end to end
- Create a manual request entry point.
On an isolated tab, add an Inject node named “Manual call LC-001.” Set the payload type to number and its value to
4. Under properties, also add the stringmsg.requestId = "LC-001"; setonce=false, and leave repeat and crontab blank. Connect it to a Change node named “Build request contract.” Use JSONata to setmsg.requestto{"requestId": requestId, "value": payload}, then deletemsg.payload. - 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 named in the next step as the target; do not use
msg.target. The wiring must be “Manual call LC-001” → “Build request contract” → “Call / offline doubling.” - Create a named callee path.
On another 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 must use only the following code, read no context, and perform 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; - Observe normal responses and timeouts separately.
Connect the Link Call output to a Debug node named “Inspect response” that displays 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 that displays onlymsg.error. In the normal test, press Inject once. The sole normal projection 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; the source type islink call, the source name is “Call / offline doubling,” and the normal Debug receives nothing. Reconnect the return wire immediately after the test. - Verify, clean up, and export.
Confirm that the complete path is exactly manual Inject → Change/request contract → static Link Call → named Link In → pure transform → return Link Out → limited Debug. It must contain no Action, HTTP, MQTT, file, scheduling, or credentials. Clear the Debug messages before making the smallest possible export and reviewing it as text. For deployment and recovery strategies, continue with Chapter 18, Debugging and testing.
Input: msg.payload = 4; msg.requestId = "LC-001"
request: { requestId: "LC-001", value: 4 }
Normal Debug: { requestId: "LC-001", doubled: 8 }
timeout Catch: error.message = "timeout"; normal Debug receives no message
External I/O: none; Link Call timeout: 2 secondsLink In, Link Out, Link Call, and return
Link In and a Link Out in its normal mode create virtual wiring between flow tabs. When a Link Out receives a message, it can forward the message to its connected Link In. This is message delivery, not a call with a return value. Virtual wires usually appear only when a Link node is selected, so each name should identify both the action and the contract—for example, “Send to / pure-data normalization,” not merely “Next.” The Node-RED 5.0.2 node documentation also states explicitly that Links cannot connect into or out of a Subflow.
Link Call is the mode that waits for a response. 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, return sends the message back to the original Link Call; only then does the Link Call emit it through its output. A regular “send to all” Link Out does not complete a Link Call.
| 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 processing it | There is no call-waiting or return contract |
| Link In | Receives a message over a virtual wire and emits it from its output | Nodes along the path must still report their own errors |
| Link Call: static | Calls a fixed Link In and waits for a return | If no response arrives before the timeout, it raises a catchable timeout error |
| Link Call: dynamic | Uses msg.target to resolve a Link In by ID, name on the same tab, then 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 only within 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 |
In Node-RED 5.0.2, the default Link Call timeout is 30 seconds. When it expires, the pending request is removed and the error mechanism is invoked, allowing a Catch node to receive the error. A timeout neither cancels the work nor suppresses a late return. If a return arrives after the timeout, the pending record is already gone, so Link Call emits the message as ordinary downstream output. Both timeout-compensation logic and downstream Link Call logic must therefore prevent duplicate or conflicting side effects from the same work. Never assume that only one path can run.
msg.request.requestId to the bounded key flow.activeLinkRequestId. On the timeout Catch path, clear this key before displaying the error. Downstream of Link Call, first pass the message through the Function below. A normal return matches and clears the active ID. A late return after timeout sees that the ID has been cleared and returns null, so it cannot reach the side effect. Concurrent requests must not share this single 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;
msg.target must not come directly from untrusted input. Version 5.0.2 first tries an exact ID. Failing that, it looks for a unique name on the same flow tab; if none is found, it looks for a unique name globally among regular flows, excluding Subflows. Duplicate global names raise an error, and Link Call cannot call a Link In inside a Subflow. If the target set is fixed, prefer a static target or map operations through an explicit allowlist in the preceding node.// 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;Subflow instances, properties, and Context
A Subflow is a reusable internal graph of nodes. Each time you drag it onto a flow, you create an instance. Changes to its definition affect its instances, so a Subflow suits “the same algorithm with different non-secret parameters,” not copies intended to diverge indefinitely. The nodes within each instance are distinct runtime instances, and their node context should likewise be treated as isolated. Deliberately reading or writing the parent flow context crosses that isolation boundary.
Subflow properties become environment values for a given instance, allowing non-sensitive parameters such as DISPLAY_LABEL and MAX_COUNT. A Function can retrieve them with env.get("DISPLAY_LABEL"), and a typed input can select the environment-variable type. The Node-RED runtime also provides instance information such as NR_SUBFLOW_ID, NR_SUBFLOW_NAME, and NR_SUBFLOW_PATH. These values help distinguish instances during diagnostics, but IDs and paths must not appear in public examples or shared records.
// 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;
With two instances of this example, each instance’s seen count should start at 1. If the requirement is genuinely for shared statistics, first document the shared state’s lifecycle, then deliberately choose flow or global context; do not use it simply because it is accessible. A Subflow can access its parent context/environment through $parent., but this increases coupling and must be documented as part of the interface. Even when a Context store uses localfilesystem, an assignment does not necessarily become durable immediately. Context is not a secret store either.
| Data | Recommended location | Reason |
|---|---|---|
| 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 |
| Token, password, or private key | Neither a property nor Context | Use managed credentials or the platform’s secret mechanism |
Do not use recursion: Never place a Subflow instance directly inside its own definition, or create a cycle in which A contains B and B contains A. This is not ordinary function recursion; it creates an architecture that cannot be expanded safely and poses a resource risk. For repeated processing, use bounded message iteration or a finite-state design instead, and test its termination condition.
Boundaries between environment variables, the Library, and Projects
In this chapter, environment variables are used only for non-sensitive, replaceable configuration, such as display text or a batch limit. Do not put tokens, passwords, private keys, or connection credentials in flow JSON, Subflow properties, Function code, Debug output, or Context. Environment-variable names and values may also be exposed through exports, diagnostics, or the runtime environment. Merely “using env” does not make a secret secure.
The Library is a reusable store within the editor: you can save flow snippets or Function code and retrieve them later. Import/export serializes the current selection to JSON or brings JSON into the editor, making it suitable for an explicit, one-time transfer. Projects provides a complete Git-backed working directory and version-control workflow involving a repository, branches, remotes, and Git identity. None of these three replaces a managed backup, and none performs secret scrubbing for you.
| Aspect | Library | Import/export | Projects |
|---|---|---|---|
| Unit | Reusable snippet or Function | One JSON snapshot | Files in a Git-backed project |
| History | Must not be treated as complete change history | You store and compare snapshots separately | Git commits and branches manage history |
| Dependencies | Nodes and configuration still require review after retrieval | Missing node types and configuration still require review on import | Packages and runtime differences still require management |
| Secrets | Scrub before saving | Scrub before sharing | The standard workflow commits the encrypted project credentials file; the repository must remain 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 available, and do not directly edit a settings file managed by the Add-on. If your organization approves enabling Projects, first create a recoverable backup, record the current state, stop the Add-on, make the change through a method supported by the Add-on, then restart and verify. Follow the pinned Add-on documentation and your change-control process for the exact procedure.Once Projects is enabled, access to Git remotes and Git credentials forms another security boundary. The standard Projects workflow in Node-RED 5.0.2 adds the encrypted project credentials file to version control. This is expected behavior, but does not make the file safe to publish: access to both the project repository and its history must remain restricted. Each project has its own project credential secret. Back it up securely and separately from the repository, or the encrypted credentials may be unrecoverable.
The Add-on’s credential_secret is ignored in Projects mode and cannot decrypt project credentials. The project repository, project credential secret, Git access credentials, and a complete Add-on backup 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.
Safe exports, configuration nodes, and rebinding
Flow JSON is readable and comparable, which also makes it easy to carry environment details elsewhere. Begin an export with the smallest possible selection; do not select an entire workspace merely for convenience. If a snippet depends on a configuration node, document the type of configuration it requires rather than retaining a real production ID. Credentials are generally stored through a separate mechanism, but that does not guarantee a clean export. Ordinary fields, Functions, Templates, Comments, environment properties, and Debug output may all contain sensitive values.
Pre-sharing scrub checklist
- 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 has been identified; in the target environment, use the editor to reselect an approved configuration node rather than replacing a JSON ID directly.
- No Inject set to run at startup, schedule, listener, external connection, file write, or action path; if the snippet retains an operational node, disable it before export and document why.
- Every Function, Template, Change, JSONata expression, Comment, node name, and flow description has been reviewed character by character.
- Only required wires remain; Link targets, Subflow definitions, and instance properties are complete and contain no recursion.
- Inspect the JSON as plain text, then import it into a blank, isolated tab; verify the node count, types, disabled state, and configuration dependencies.
Shared package notes (no real identifying data)
- Requires: Node-RED 5.0.2 core nodes
- Input: finite numeric msg.payload
- Config nodes: none
- Environment property: DISPLAY_LABEL (non-secret text)
- External I/O: none
- Runs automatically at startup: no
- Tested: normal value, missing value, wrong type, and isolation between two instances
Before importing unknown JSON, inspect type, wires, d, the Inject node’s once setting, and all configuration fields in a text viewer. If a node type is missing, do not immediately install a package just to clear the warning; first verify the source, pinned version, and necessity. This section’s checklist is self-contained. For complete procedures covering credentials, backup and restore, Projects, and security hardening, continue with Chapter 21, Backups and security.
Troubleshooting
- Link Call waits until it reports a timeout: Confirm that the target is a Link In, that every success and rejection path ends at a Link Out in return mode, and that a regular send-to-all Link Out has not been mistaken for return. Scope a Catch node to the call to observe its errors; do not merely lengthen the timeout to hide a missing path.
- The return node warns that no return source exists: The path may have been entered through a regular Link or Inject rather than a Link Call. Keep one-way and call entry points separate so they do not share a return that is valid only within a call chain.
- Values in two Subflow instances affect each other: Check whether the implementation uses flow/global or
$parent.context instead of node context within the instance. Then send manual inputs in A→B→A order and verify each count independently. - An unknown configuration node or red triangle appears after import: Do not edit the reference ID by hand. Open the node that uses the configuration node, confirm its type and fields, then reselect an approved configuration in the target editor. If no approved configuration is available, leave the node disabled.
- Projects is not available: Projects is disabled by default in this Add-on; this is not a browser problem. Do not edit settings directly. Follow the approved process for backup, shutdown, controlled modification, restart, and recovery.
- An export appears to contain no credentials but still cannot be shared: Search ordinary fields for environment IDs, URLs, comments, Function strings, Subflow properties, and configuration nodes. Have another person review the plain text; do not rely solely on the export dialog’s scope summary.
Pinned versions and official documentation
The exact commits below define this chapter’s behavioral baseline. The official documentation supplements the operating concepts. If rolling documentation differs from the pinned versions, follow the implementations in the pinned Node-RED 5.0.2 and Add-on 22.0.1 commits.
- Pinned Node-RED 5.0.2 commit: core Links, the Subflow runtime, Context, the Library/import-export features, and the Projects API.
- Pinned Home Assistant Community App: Node-RED 22.0.1 commit: Projects defaults and the
credential_secretboundary. - Official Node-RED documentation: Subflows.
- Official Node-RED documentation: Environment variables.
- Official Node-RED documentation: Importing and exporting flows.
- Official Node-RED documentation: Projects.
Frequently asked questions
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 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 continue to serve separate purposes.