Skip to Content

Reuse a flow across tabs, then stop trusting the first green light

reuse it, then doubt it
Node-RED Guide · Part 9

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.

30 sec
The default Link Call timeout in Node-RED 5.0.2. After it, the pending request is dropped and a catchable timeout error fires — but the original work is not cancelled
false
The default of editorTheme.projects.enabled in Add-on 22.0.1. Projects stays off until you deliberately turn it on
3 scopes
Full, Modified Flows, and Modified Nodes — the deployment scopes Node-RED 5.0.2 gives you, smallest to largest
Two different disciplines

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

ToolBest suited toWhat it does not guarantee
Link In / Link OutOne-way virtual wiring across tabsIt is not a function call. It does not return automatically
Link Call + returnA time-bounded request/response exchangeA timeout neither cancels the work nor suppresses a late return
SubflowReusing one internal graph through several instancesIt does not share one node context across instances, and cannot recursively contain itself
LibrarySaving flow or Function content for reuse in the editorIt is neither deployment history nor a remote Git workflow
Import/exportMoving a snapshot of flow JSONIt is not evidence of trust, and it does not remove environment-specific identifiers for you
ProjectsGit-backed projects, branches, and version controlDisabled 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.

Looks safeThe node under test shows a calm green dot. That dot is a status text the node chose to publish. It says nothing about whether the last message you sent through it actually succeeded.
Looks finishedmsg.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.
Looks doneA Complete node fires for the node you selected. That is the node telling the runtime “I am finished with my own part.” It is not a promise that anything downstream, or any external system, has finished too.
Everything built in this part is offline and hands-only. Every example manipulates only strings and numbers inside a message, using manual Inject nodes, pure Function nodes, and limited Debug nodes. None of it touches the network, a file, a Home Assistant action, or any other external I/O. That restriction is deliberate: it lets you learn the exact behavior of a Link Call timeout, a Subflow instance, or a Catch scope without a second system's flakiness getting mixed into the result.
Subflow instances

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.

Answer these before you choose a Subflow, not after: its input/output shape; the non-secret default for every instance property; who owns node, parent, and global context; the expected interleaving when two instances run A→B→A; how errors leave the Subflow; which instances a definition update will touch; and the check that prevents recursion. This is a design checklist, not a hands-on exercise — once every item has an answer, validate it in its own isolated example, separate from the Link Call walkthrough above.

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.

DataRecommended locationReason
A temporary count for one internal nodeNode contextNaturally isolated by instance
An instance's display label or limitSubflow propertyEach instance can set it explicitly
Non-secret state shared by several nodes in the parent flowExplicit parent flow contextCross-boundary sharing must be documented in the contract
A token, password, or private keyNeither a property nor ContextUse 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
Three boxes with nothing leaving themEach instance's solid arrow goes to its own node-context box, and neither of those two boxes connects to the other — that is the isolation the table above describes. The dashed arrows to the shared parent context exist only because this diagram is showing you the deliberate escape hatch; a Subflow whose code never writes $parent. draws no dashed arrow at all.
In plain terms

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.

Env vars, Library, exports

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.

AspectLibraryImport/exportProjects
UnitA reusable snippet or FunctionOne JSON snapshotFiles inside a Git-backed project
HistoryMust not be treated as complete change historyYou store and compare snapshots yourselfGit commits and branches manage history
DependenciesNodes and configuration still need review after retrievalMissing node types and configuration still need review on importPackage and runtime differences still need managing
SecretsScrub before savingScrub before sharingThe standard workflow commits an encrypted project credentials file; the repository must stay private, and the project credential secret must be stored separately
Add-on boundary. In Add-on 22.0.1, 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.
Requires — Node-RED 5.0.2 core nodes only.
Input — a 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.
In plain terms

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.

Debug, minimally

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.

Version note for 22.0.1 / 5.0.2. The add-on's bundled packages include a source-map package, but the pinned source alone does not prove it is registered or enabled. Do not claim that stack traces from the 5.0.2 runtime necessarily include source-map mappings. The rule underneath stays the same either way: limit what you log and what you trace, and sanitize it before it leaves the editor.
PracticePurposeCost or risk
One property to the sidebarConfirm its type and a local resultYou still have to confirm that property itself is not sensitive
Complete msg to the sidebarBriefly explore an unknown shapeHigher serialization, copying, and rendering cost, with greater exposure
JSONata projection to the sidebarSelect only allowlisted fieldsThe expression itself needs testing against missing values and errors
Output to the runtime logLimited diagnostics when the editor is not openRetention periods, authorized readers, and centralized-log boundaries differ from the editor
Node status displayShow a very short summary or countLength-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;
Performance and privacy sit on the same side. Sending complete, high-frequency messages to Debug adds formatting, transmission, and sidebar processing cost on top of the exposure risk. Enable the minimum output only for the bounded window you actually need, limit both the fields and the number of cases, and disable or remove the node the moment you are done. Handle any log it generated according to your own data-retention rules, not by leaving it running “just in case.”
Catch, Status, Complete

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.

NodeWhat it observesOutput focusWhat it cannot establish
DebugAn ordinary msg actually received by the nodeA selected property, the complete msg, or a JSONata resultNo message does not necessarily mean an upstream error occurred
CatchA catchable error reported by a node inside its scopemsg.error plus source informationIt does not represent every failure, rejection, or status change
StatusA status event emitted by a node inside its scopemsg.status; it never creates a payloadStatus text is not the result of processing a message
CompleteThe selected node telling the runtime it has finished processing a messageTriggers a separate flow pathNot 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"]
One node, four independent channelsFour labeled arrows leave the same Function node, one per channel, and none of the four end boxes connects to any other. That is the whole point of this diagram: a payload sitting quietly in Debug tells you nothing about whether Catch, Status, or Complete fired for that same message, because each one is watching a different thing the node did.

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.

In plain terms

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.

ObservationCheck firstReason
Function validation rejects inputCatchnode.error(..., msg) is what creates a catchable error
A node shows connecting/connectedStatusThis is node status, and it is not necessarily tied to one msg
An ordinary data result is wrongLimited Debug + invariantsNo error or status event may exist at all in this case
A selected node finishes processingCompleteThe node has to support the completion API for this to apply
Catch and Status stay disabled in the downloadable example. Because they are operational observation nodes, this site's own safety rules ship them with 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.

StatementCan Complete alone establish it?Qualification
The selected node called the completion APIYesThe node's own implementation defines the exact semantics
Downstream received the selected node's outputNot necessarilyCompletion and downstream execution have different scopes
Every node in the entire flow has finishedNoThere is no automatic whole-graph join semantic
The external service has permanently completed the workNoThe protocol needs an explicit acknowledgement or a verification step
No Complete event means the node failedNoThe 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.

  1. Step 1

    Read it as text first

    Confirm it contains 12 nodes, that all three Inject nodes use once=false with repeat and crontab left blank, and that the Function node has no lifecycle hooks, modules, timers, or I/O.

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

  3. 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 of msg.complete, not a complete message, and it does not mean the downstream Debug node has itself finished.

  4. Step 4

    Restore Complete, then test the other two, one at a time

    Immediately set Complete back to d: true after the test. Enable only the corresponding observer for each of the remaining paths. The Catch Inject makes the error Debug receive message TEST_ERROR with a source pointing to the same Function inside msg.error. The Status Inject makes the status Debug receive fill blue, shape dot, text TEST_STATUS, with a source pointing to that Function inside msg.status. Neither path produces any ordinary payload output.

Interpretation boundaries. Ordinary output and Complete are independent observation paths. Catch keeps reading only 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.
Testing in stages

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.

StageTestPass condition
1. Pure transformationManual Inject → pure Function/Change → limited DebugNormal, boundary, and invalid types all behave as expected
2. Error contractTargeted Catch → error-projection DebugEach rejected case enters only its expected error path
3. Status/CompleteTargeted Status and Complete nodes, recorded separatelyNeither status nor node completion is treated as end-to-end success
4. Architectural boundariesTwo Subflow instances, or a Link Call timeoutState isolation, return, and timeout contracts all hold
5. Pre-integration reviewExternal I/O kept disabled; review config, targets, recoveryYour change process separately approves entry into the integration environment
6. RegressionCore cases + the current fix's case + smoke cases for adjacent flowsNo 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.

When it does not add up

The failures that actually happen, matched to the fix

SymptomLikely causeWhat 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
Questions people ask

The ones that come up again and again

What should I do first if a Projects repository accidentally becomes public?
Stop synchronization and automatic pushes first, move the remote back to a controlled private location, and revoke the associated Git access credentials. Then treat the project credential secret and any tokens or passwords that might appear in flows, configuration, or history as compromised, and rotate them. Deleting the current files does not remove them from Git history — rewrite or quarantine the contaminated history according to your organization's process, then verify the environment again from a trusted backup.
Can I reuse a requestId immediately after a timeout?
No. An earlier call may still return later, and reusing its ID stops the gate from telling the two generations apart. Use a fresh, opaque requestId for every call. Remove the pending record on timeout, and discard any late return outright — it must never reactivate the old work.
Which regression is easiest to miss after changing a Subflow definition?
Testing only one instance. Create at least two instances with different properties and interleave input in A→B→A order. Confirm node context does not leak between them, that any sharing through parent flow or global context matches the design, and that every tab and error exit that uses the Subflow still behaves correctly.
Should environment variables ever hold tokens?
Not in this part's scope. Subflow properties, environment values, Context, Debug output, and exports can all expose data. Put secrets in managed credentials or a secret mechanism the platform provides, and scrub every field before sharing regardless.
Does enabling Projects give me a backup?
No. The standard Projects workflow tracks an encrypted project credentials file, but the repository must not be public, and the project credential secret has to be backed up separately and securely. The add-on's own 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?
No. Keep the affected nodes disabled, then use the editor to select approved configuration nodes of the correct type in the target environment. Guessing or pasting a real ID can bind the wrong configuration, and it can leak environment data into the file you meant to share.
Does Complete mean the entire flow has finished?
No. It triggers only when the selected node calls the completion API. It does not mean every downstream node has finished, and it does not mean an external system has reached its final state. Not every node supports completion at all.
Why not just leave complete-message Debug output enabled all the time?
A complete msg raises both the sensitive-data exposure and the formatting, transmission, and sidebar workload. Prefer allowlisted properties, enable Debug only for a bounded reproduction window, and disable or remove it once you have what you needed.
Which regression tests should I run after fixing a Function?
At minimum: the original core success cases, every existing negative case, a reproduction of this specific bug, the invariants, and smoke cases for adjacent input and output shapes. Then validate using the smallest deployment scope that is genuinely sufficient, so you are not restarting flows this fix never touched.
How do I prevent an error loop if the Catch compensation path itself fails?
Give the compensation path an independent, mutually exclusive Catch scope, and add a bounded error stage or retry count to the message. At the limit, record only minimal diagnostics and stop; do not send the message back to the original entry point. Compensation must not trigger the same external side effect again, and its error text must not include the complete original message.
Status changes repeatedly within a short period. What should the test examine?
Do not capture only the final color. Use limited manual cases to record the status sequence, source, timing, and whether it exceeds the permitted frequency. If a downstream path raises alerts, apply deduplication and throttling first. Status is still not a business-success signal, and jitter must not cause an Action to be resent.
Can I deploy the example immediately after downloading and importing it?
No. First read the JSON and README offline, then inspect every node in an isolated copy. Catch, Status, and Complete are fixed at d: true; any activation and deployment requires separate approval.
Next

Where to go from here

the trail runs to the network next

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 guide

Part 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

JSONata, Mustache, and the Function node: four tools that look alike and share nothing