JSONata, Mustache, and the Function node: four tools that look alike and share nothing
A field in Node-RED that looks like a “template” might be processed by any one of four completely different engines, and pasting the wrong syntax into the wrong field does not always fail loudly — sometimes it just produces a value that looks right and has the wrong type. This part pins down which engine runs where, works through a fixed, offline JSONata transform step by step, and then spends the rest of its length on the Function node: the three ways to finish a message, when to reach for two outputs, and how to keep an error catchable instead of just logged and forgotten.
A brace on screen is not proof of what will actually run
When all you need is a Celsius-to-Fahrenheit conversion, JSONata in a Change node shows you the input and output more clearly than a page of JavaScript would. When you need one line of readable text, Mustache in a Template node is the more direct tool. The trouble is that JSONata, Mustache, Function-node JavaScript, and Home Assistant Jinja all lean on variables, parentheses, or braces — and none of the four shares syntax, types, context, or even the machine it runs on with any of the others. Pasting a working expression from one field into another does not always throw an error. Sometimes it produces a string that looks plausible and carries the wrong type into the next node.
This chapter targets Node-RED 5.0.2 and Home Assistant WebSocket nodes 0.80.3. The rule for choosing between the four is simple to state and easy to forget under pressure: start with the least capable tool that meets the requirement, using the comparison in the next section. If a transformation grows extra branches, needs explicit error objects, or turns into a loop with several exit conditions, move it into a Function node rather than stretching JSONata into something unreadable.
Which node owns the field decides which language runs
Work out which node provides a given field before you choose its syntax. A pair of braces on screen does not by itself tell you whether you are looking at JSONata, Mustache, or a Jinja template Home Assistant will evaluate somewhere else entirely.
flowchart TD
A["A field that looks like a template"] --> B{"Which node owns it?"}
B -->|"Change or Switch,
type set to expression"| C["JSONata"]
B -->|"Template node"| D["Mustache"]
B -->|"Function node"| E["JavaScript"]
B -->|"Render Template node"| F["HA Jinja"]
C --> G["Runs inside Node-RED itself"]
D --> G
E --> G
F --> H["Runs inside Home Assistant,
reached over the network"]
| Environment | Where it runs | Input root and result type |
|---|---|---|
| JSONata | Inside Node-RED, wherever a typed input is set to expression — typically Change or Switch | The root is the whole message, so you write payload, not msg.payload. Can return a string, number, Boolean, null, array, object — or no result at all |
| Mustache | The core Template node | Looks up message fields, plus explicit flow/global context tokens. Renders text first; if the Template's output format is JSON or YAML, that text is then parsed into the matching type |
| Function JavaScript | The Function node's own sandbox | Reads the message through msg, with node/flow/global context APIs available. Output is any JavaScript value you build, sent as a message object or an array of them |
| HA Jinja | Rendered by Home Assistant itself, after the Render Template node sends it there | Uses HA's own template environment, not Node-RED's msg or context. In 0.80.3 the result is defined as a string, written to the configured location |
Escaping behavior differs just as sharply. JSONata performs no text escaping of any kind — it is not a text template, so validate its output against whatever the next node expects. Mustache's double braces apply HTML escaping by default; triple braces skip it, and are safe only when you already know the output context. JavaScript applies no escaping automatically for HTML, JSON, or notification text; you write that logic yourself. HA/Jinja applies its own rules on the Home Assistant side, and if the rendered string will be treated as JSON on the way back, Node-RED still has to parse and validate it explicitly — HA does not do that for you.
Choosing between them by requirement
| Requirement | Preferred tool | Stop condition — move up a level |
|---|---|---|
| Set, move, or delete one property | Change | Add a Switch first if the input shape has not been validated yet |
| Compute a new object from a fixed JSON structure | JSONata | Move to a Function node once it scans an unbounded collection or gets hard to read at a glance |
| Compose a short notification or explanatory line | Mustache Template | Choose another tool the moment you need to insert a whole object or produce a non-string value |
| Produce multiple outputs, explicit errors, or real branching | Function JavaScript | Do not add external I/O, unbounded loops, or arbitrary modules just because you are already in a Function |
| Use HA's own template entity/state semantics | HA Jinja via Render Template | Do not reach for it when the work must stay offline, low-latency, or confined to Node-RED alone |
| Turn external text into a structure | The matching core parser — JSON, CSV, HTML, XML, YAML | Do not parse anything until its size and shape are bounded first |
One more thing worth internalizing before the next section: the same field name does not guarantee the same type in all four environments. A Home Assistant state is very often a string; JSONata multiplication expects a number; Mustache only ever inserts values into text. Guard every transformation with an explicit type check rather than trusting implicit conversion to do the right thing.
payload, not msg.payload — and what “no result” actually means
JSONata is a functional, declarative language built for JSON structures. In a Node-RED typed input, the document JSONata reads is the entire incoming message — not a wrapper around it — so the correct path to the payload's temperature field is payload.temperature.
payload.temperature — correct. The message itself is already the root document.msg.payload.temperature — looks for a top-level property literally named msg, which almost never exists, and usually will not return the value you meant.A fixed, offline safe flow
Picture a flow with exactly one manual Inject node, one Change node, and one Debug node — nothing else wired in, no schedule, no external I/O. The Inject node's repeat and crontab fields are empty strings and once is false, so nothing fires on its own. The Change rule targets msg.payload and its source type is set to JSONata expression:
{"Celsius": payload.temperature, "Fahrenheit": payload.temperature * 9 / 5 + 32}For a manual payload of {"temperature":25}, the resulting msg.payload is an object holding 25 degrees Celsius and 77 degrees Fahrenheit — an object, not a JSON string. Before deploying anything you download or write yourself, read the flow as text first: confirm the Inject node really has no schedule, and confirm there are no server configurations, credentials, URLs, or unfamiliar nodes hiding in it. Deploy the smallest possible scope, press Inject exactly once, and check the payload's type and both fields in Debug — do not hammer Inject to stress-test a flow that exists only to teach one transform.
Boundary testing is where this gets instructive. Change the manual payload to {"temperature":0} and you should see 32 degrees Fahrenheit; at {"temperature":-10}, 14 degrees. Then change it to an empty object, {}. Debug now receives msg.payload as {} — the expression still returns an object, but both properties are omitted because their source values were undefined. Had the expression been only payload.temperature on its own, the whole expression would instead return no result at all, which is a third, distinct outcome.
flowchart TD A["Fixed JSONata expression:
build a Celsius/Fahrenheit object"] --> B{"What does the manual
Inject payload contain?"} B -->|"temperature: 25"| C["Full object returned:
Celsius 25, Fahrenheit 77"] B -->|"empty object {}"| D["Object still returned,
both fields omitted"] B -->|"no temperature property,
and expression is just
payload.temperature alone"| E["No result at all —
not even an empty object"]
Think of three different states of an in-tray. A full object with both fields is a folder sitting in the tray with all its paperwork inside. The empty object {} is a folder still sitting in the tray, labeled, present, just empty inside. “No result” is not a folder at all — there is nothing in the tray to open. A Switch node that only checks “is there a folder” will treat the second and third cases the same way and miss the difference that actually matters to whatever runs next.
Bounding an array before you transform it
A safe transformation states its output shape up front instead of hoping the input behaves. This example keeps only the first 20 entries of an array and only the fields it explicitly names, with no I/O and no settings lookup:
(
$items := payload.items[type = "reading"][[0..19]];
$items.{
"name": $string(name),
"value": $number(value),
"valid": $type(value) = "number"
}
)[[0..19]] selects an index range — twenty items here, but the real ceiling belongs to your own data contract, not to this example. If the input could be either a single object or an array, an array constructor keeps the output shape stable, but you still have to test null values and the no-result case on their own; do not assume one test covers both. When JSONata cannot find a path it may return no result, and that is a third state again, distinct from an explicit null, an empty array, or false.
Helpers that exist only inside Home Assistant nodes
HA WebSocket nodes 0.80.3 injects extra JSONata functions into its own nodes' JSONata service. The core Change node does not gain them automatically — they read entity, device, and area data the Home Assistant integration currently holds, and they cannot be carried over into Mustache, Function JavaScript, or HA Jinja.
| Helper | What it does in 0.80.3 | Usage boundary |
|---|---|---|
$entity() | The entity object that triggered the current node | Not every node or event has one; check for undefined first |
$prevEntity() | The previous-state entity, from an event node | May not exist during initialization, creation, or deletion |
$entities() / $entities(entity_id) | Every cached entity, or one chosen by entity ID | The full collection can be large; use a placeholder ID such as sensor.YOUR_SENSOR in any shared example, never a real one |
$areas(lookup) | Every area with no argument; an area, entity, or device ID as lookup | Areas and IDs are specific to your own home; a result may not exist |
$areaDevices(areaId) / $areaEntities(areaId) | Devices or entities tied to one area | Constrain the area first, then cap the result count so each message does not scan a large collection |
$device(lookup) / $deviceEntities(device_id) | Find a device by entity ID or name, or list its entities | Names can change or be duplicated; never place a real identifier in a shared flow |
$outputData(name) | Extra output data passed to the node's JSONata service; omit name for everything available | Available keys depend on the calling context — do not assume a fixed shape |
$sampleSize(collection,n) / $randomNumber(lower,upper,floating) | Lodash-style sampling and random numbers, exposed in 0.80.3 | Non-deterministic by design — never use them for a security, authorization, or control decision that has to be reproducible |
This chapter's safe flow deliberately avoids every one of these helpers, since it has to work with no Home Assistant connection at all. If a real flow needs them, build independent test messages with placeholders first, cap how many results come back, and show only selected, non-sensitive fields in Debug — not the full entity object.
Text in, text out — even when the field looks like JSON
The core Template node in Node-RED 5.0.2 runs Mustache. Double braces look up values in the incoming message. This plain-text template reads msg.payload.label and msg.payload.value:
Reading name: {{payload.label}}Reading result: {{payload.value}}What comes out is text, always. If a value contains & or an angle bracket, double braces HTML-escape it by default; triple braces switch that off. Triple braces are not a general “fix garbled output” switch: when the destination is HTML, raw interpolation can let untrusted content get treated as markup. When the destination is JSON, do not rely on string concatenation to handle quotes, backslashes, and newlines correctly — build the object with JSONata instead, or let the Template node's JSON output format parse a fixed template and validate the resulting type afterward.
Double braces are a clerk reading your note back to you aloud, spelling out anything that could be mistaken for punctuation or an instruction — “ampersand,” “less-than sign” — so nothing in your handwriting gets misread as something the room should act on. Triple braces hand over the actual paper, symbols and all. That is fine when you already know nobody downstream reads that paper as instructions. It is not a fix for messy output; it is a decision to stop protecting the reader.
A Template node can write its result to a msg, flow, or global location, and beyond explicit flow/global context tokens, Mustache can read an environment value through an env.NAME token. In 5.0.2, when the node's own template field is left empty, it falls back to whatever arrives in msg.template instead. Do not let an untrusted upstream source control that: it effectively gets to pick which environment and context values get rendered. For the same reason, never place secrets or credentials anywhere a template can read them. The examples in this chapter use only fixed templates and touch neither environment nor context.
That does not make context a private scratchpad for a template, either. If a value is going to persist across messages — or across restarts, depending on the store — its lifecycle and cleanup rule needs deciding first; see the context boundaries in an earlier chapter. JSONata's own $flowContext(), $globalContext(), and $env() carry the identical secret-handling boundary, and this chapter's fixed expressions do not touch any of them.
One message, four ways to finish it
Change, Switch, JSONata, and Template cover most straightforward transformations. Reach for a Function node when a rule needs multiple outputs, explicit type guards, reusable helper logic, or a consistent error shape. Because a Function node runs real JavaScript, it is also far more exposed than the declarative nodes to unbounded loops, shared state that outlives one message, messages sent twice by accident, and asynchronous work that is hard to follow later.
This chapter is pinned to Node-RED 5.0.2. The core Function runtime executes your code in a sandbox, hands it msg, node, and the context APIs, and understands both a synchronous return value and a returned promise. A Function node is not an ordinary Node.js file: do not assume an unrestricted require is available, and never let message content decide which module loads. If the job is really just building a new object, that belongs back in the JSONata section above — reach for Function only once a lighter tool genuinely cannot do it.
| Mode | Output | Best suited to, and the common mistake |
|---|---|---|
Synchronous return msg | One message, or an array arranged by output | Pure transformations and routing. Mistake: returning a bare primitive instead of a message object, or also calling a delayed node.send on the same path |
return null | No output at all | Explicitly dropping invalid input. Mistake: treating null as a payload worth sending |
node.send + node.done | Sends a message once the work finishes | Genuinely asynchronous work. Mistake: omitting node.done() on some path, calling it twice, or also returning a message from the same run |
node.error(err,msg) | Not a normal output; reaches a scoped Catch node instead | Recoverable, observable failures. Mistake: logging only a string with no msg attached, or putting a sensitive payload inside the error |
node.done(). In an asynchronous Function, do not alias it or reach it through a computed property — call node.done() directly, exactly once, on every completion path.flowchart TD
A["A message enters the Function node"] --> B{"How does the code
finish this message?"}
B -->|"return msg (or an array)"| C["Synchronous completion.
The runtime marks it done
once the result settles"]
B -->|"return null"| D["No output.
The path simply ends"]
B -->|"node.send(msg), then
node.done()"| E["Asynchronous completion.
node.done() must run
exactly once on this path"]
B -->|"node.error(err, msg)"| F["Goes to a matching Catch node,
not to a normal output"]
Think of a coat-check ticket. The moment a message walks in, the runtime is effectively holding out a numbered stub, expecting it back exactly once when the job is done — whether that is instantly, by handing the coat straight over, or ten minutes later, once you have found it in the back room and called node.done(). Handing back two stubs for one coat, or never handing one back at all, is the mistake this rule exists to catch.
A Function's output must always be a message object. You can put a bare number in msg.payload, but you cannot return 42 directly. The _msgid field exists for message tracing; do not delete it or repurpose it as a business key.
A safe flow to try
Picture a flow with a manual Inject node, a Function node, and a Debug node, and nothing else. Its initialize and finalize fields stay empty strings and libs stays an empty array — leave them that way rather than experimenting with lifecycle hooks on this flow. Before importing anything, read the JSON first: the Inject node's repeat and crontab should be empty and once should be false; the Function node should have one output, no modules, no lifecycle code; the Debug node should show only payload and never write to the console.
The Function itself accepts only a finite JavaScript number, then builds a new payload holding the original value and its square:
if (typeof msg.payload !== "number" || !Number.isFinite(msg.payload)) {
node.error(new Error("payload-validation-failed"), msg);
return null;
}
const value = msg.payload;
msg.payload = { originalValue: value, square: value * value };
return msg;It stores nothing and touches nothing external. Feed it 6 and Debug should show 36 for the square. Deploy on a separate tab with no wires reaching any other flow, press Inject once, and confirm the payload shape by eye.
Then test the type boundary on its own. In an isolated temporary copy — never the flow above — feed the same Function Number.NaN and Number.POSITIVE_INFINITY through two separate manual Inject nodes, with a Catch node scoped only to this Function and a Debug node showing only msg.error. Both invalid values must produce a Catch output whose msg.error.message reads exactly payload-validation-failed, while the normal Debug node receives nothing at all. JSON itself cannot represent either value directly, which is exactly why this needs a dedicated test rather than a plain JSON message typed by hand. Delete every temporary node once you have confirmed both boundary cases; keep the original Function's contract strict — negative finite numbers remain valid input.
Narrow the input first, then process it, then build a fixed shape
Synchronous returns and multiple outputs
The simplest pattern sets fields on the original message and returns it. If you build a new message object instead, copy across only the fields downstream nodes actually need — do not spread the whole input into it. In 5.0.2 the runtime assigns the current input's _msgid to every valid output message automatically.
Configure a Function with two outputs and the outer array's positions correspond to output 1 and output 2; a null in either position means nothing is sent there. This routes a valid value to the first output and an error summary to the second:
if (typeof msg.payload === "number" && Number.isFinite(msg.payload)) {
const value = msg.payload;
msg.payload = { ok: true, value };
return [msg, null];
}
msg.payload = { ok: false, reason: "not-finite-number" };
return [null, msg];To send several messages through the same output in sequence, nest an array inside that output's own position. Here the first output sends two messages and the second sends none:
const first = { topic: msg.topic, payload: { index: 0, value: "A" } };
const second = { topic: msg.topic, payload: { index: 1, value: "B" } };
return [[first, second], null];flowchart TD
A["Two-output Function node"] --> B{"What shape does
return produce?"}
B -->|"[msg, null]"| C["Output 1 gets msg.
Output 2 gets nothing"]
B -->|"[null, msg]"| D["Output 1 gets nothing.
Output 2 gets msg"]
B -->|"[[first, second], null]"| E["Output 1 gets two messages,
first then second.
Output 2 gets nothing"]
In a two-output Function, [first, second] means one message per output; [[first, second], null] means two messages for the first output and none for the second. Every non-null element must be an actual message object — never a bare primitive in a position where a message belongs, and never a payload array mistaken for the outer output array.
Genuine asynchronous work
This example uses an already-resolved promise purely to show the shape of the async API; the calculation itself is still in-memory only, with no timer and no external I/O. Because it calls node.done(), both the success and failure paths complete explicitly, and nothing is returned at the end:
if (typeof msg.payload !== "number" || !Number.isFinite(msg.payload)) {
node.error(new Error("payload must be a finite number"), msg);
node.done();
return;
}
const value = msg.payload;
Promise.resolve(value)
.then((input) => {
msg.payload = { input, squared: input * input };
node.send(msg);
node.done();
})
.catch((err) => {
node.error(err, msg);
node.done();
})
return;A synchronous square belongs in a synchronous return; there is no reason to wrap it in a promise. Real asynchronous work needs its own explicit timeout, cancellation, and duplicate-send strategy, none of which this chapter's offline examples demonstrate, since none of them perform real external waits.
Bounding input, and cloning on purpose
For array input, fix a maximum item count first, and make every loop terminate at that bound rather than at whatever length the input happens to have:
const source = Array.isArray(msg.payload) ? msg.payload.slice(0, 20) : [];
const readings = source
.filter((item) => item
&& typeof item.value === "number"
&& Number.isFinite(item.value))
.map((item, index) => ({
index,
value: item.value
}));
msg.payload = { count: readings.length, readings };
return msg;This code accepts arrays only, processes at most 20 items, and outputs only an index and a value. Set the real limit for your own device and message frequency, and write it down in a Comment node or a test contract rather than leaving it implicit. Never recursively walk an object of unknown depth, never write an unbounded while loop, and never dump a large Buffer into Debug as text.
Messages can travel along more than one wire, so mutation and cloning have to be deliberate. In 5.0.2, a Function node's node.send clones the first message it sends by default. A second argument of false skips that clone, and it belongs only to a known-uncloneable object whose whole lifecycle you understand — keep the default in every ordinary flow.
- Do not mutate a message, or a deeply nested part of it, after sending it; other branches may still be reading it.
- Building two different messages means building two separate objects with their own payloads — not mutating, sending, then mutating the same reference again.
- Never pass a live object — a request/response object, an uncloneable object, a huge Buffer — through Function, Delay, or context. Extract only the data you actually need first.
- Cloning is not masking. If a message carries a sensitive field, cloning only multiplies the copies; strip that field before it reaches Debug or storage.
External modules stay off by default
The Function editor can list external modules, gated by the runtime's functionExternalModules setting. When modules are prohibited outright, 5.0.2 rejects any Function that lists libs. When they are allowed, the runtime loads only the fixed modules the node names — that is still not the same thing as an unrestricted require. This chapter's safe examples keep libs empty throughout and never let a message choose a package. A production need for a real package is a separate, deliberate change: an administrator pins its version, reviews its supply-chain and license risk, and introduces it on its own — not smuggled into a data transformation.
Give errors, logs, status, and Catch their own separate jobs
node.error(err,msg) attaches an error to the message that caused it, so a Catch node scoped to match can receive both together. Calling node.error(err) alone, with no msg, does not give a Catch node anything to associate the failure with. For expected invalid input, build an Error that omits the raw sensitive value, attach the message, and return null:
if (typeof msg.payload !== "number" || !Number.isFinite(msg.payload)) {
const err = new Error("payload-validation-failed");
node.error(err, msg);
return null;
}
const value = msg.payload;
msg.payload = { ok: true, value };
return msg;node.log, node.warn, and node.error feed the runtime's logging pipeline; node.debug and node.trace depend on the configured log level. Never log a whole msg, a raw Home Assistant event, an identifying value, or a credential. node.status is for short-lived, normal states an operator needs to recognize on the canvas at a glance — it is not a persistent monitor, and it does not raise a Catch event on its own.
| Channel | Belongs here | Does not belong here |
|---|---|---|
| Normal Function output | A message that meets the downstream contract | An error stack, or raw untrusted input |
node.error(err,msg) + Catch | A catchable failure and the message tied to it | A normal branch, or anything sensitive |
| Runtime log | Short, sanitized, aggregable text | A full msg, a token, an environment identifier, or a large object |
node.status | A transient node state, and scoped Status events | A business database, a guarantee of success, or long text |
| Debug | Selected fields during manual testing | A long-running stream of complete messages |
On Start, On Stop, and context
The Function editor's On Start (the underlying initialize field) may be asynchronous: the runtime waits for it, and for any listed modules, before it starts processing queued messages, and a failed initialization stops normal processing from beginning at all. On Stop (finalize) runs on close; 5.0.2 cleans up outstanding timers created in the sandbox but does not let the close function send a message.
None of that means this chapter needs it. Its downloadable examples leave initialize and finalize blank on purpose, because a deployment or a restart runs startup code in a way a manual Inject never does. Prefer representing any prerequisite as an explicit message, so every run stays reproducible on its own.
Node, flow, and global context give a Function get/set access at different scopes, with a named store selectable where the runtime allows it. Context suits a small amount of flow state with a clearly defined lifecycle — not a secret vault, a task queue, or unbounded history. Some stores are asynchronous and need callback forms; never assume every store reads and writes synchronously.
A message is the work order that travels with the job and tells the next station everything it needs. Context is a note taped to the machine itself. The note is handy for the one thing that genuinely belongs to that machine and nowhere else — but it survives only as long as somebody remembers it is there and nobody wipes the machine down. If a value can be worked out again from the message, it belongs on the work order, not taped to the machine.
- If it can be derived from
msg, do not store it — a pure Function is the easiest kind to test, and it is unaffected by whatever a deployment or restart left behind. - If it must be stored, fix the key, the shape, the initial value, the maximum size, the update atomicity, and the clearing rule together, up front.
- Do not write a large object on every single message; writes to a persistent store carry a cost, and not every set becomes durable right away.
- Do not store secrets or a complete Home Assistant object in context — Debug output, an export, or a backup can expose it indirectly.
Name a Function for what it does to the message — “validate the value and calculate its square,” not “process data” — and record its input shape, output count, error branch, and limits in the node's own information field. Once the code outgrows one screen, split it into small pure helpers or separate nodes rather than folding routing, storage, networking, and presentation into one Function. Test every change against normal values, boundary values, wrong types, and null before wiring the Function into anything real.
The failures that actually happen, and where to look first
| Symptom | Likely cause | What to check |
|---|---|---|
| JSONata shows no result | The root path is msg.payload instead of payload, or the path or index is wrong | Confirm the root first, then test field spelling and array index with a fixed Inject message. Test “no result,” null, an empty array, and false separately — never collapse them into one falsy check |
| A calculation returns a string or NaN | The Inject payload type does not match what the expression assumes | A Home Assistant state is very often a string; this chapter's offline examples must supply an actual number. Use $number() explicitly, after confirming the value is in range |
| Mustache shows an HTML entity in the output | Default double-brace escaping, not corrupted data | Confirm the output context. Accept the escaping for plain text; do not switch to triple braces purely for appearance in HTML. Use JSONata instead when the result must stay an object |
| JSON/YAML Template output fails to parse | Unescaped dynamic text inserted directly into the template | Reduce it to a fixed template and a fixed test value, then check quotes, line breaks, and output format. For a dynamic object, build it with JSONata directly instead |
| HA helper functions are missing in a Change node | Those helpers exist only in HA nodes' own JSONata service | Restructure to use plain message input, or move the helper into a supported HA node field |
| Function reports a non-message returned | A number, string, or array was returned where a message object belonged | Check every non-null element passed to return or node.send. Put raw values in msg.payload, never send them directly as the message |
| The second output never receives anything | The outer array shape does not match the node's output count | Confirm the node is configured with two outputs and the array is [first, second]. A nested array in the first position means several messages for that one output, not two outputs |
| A message arrives more than once | The same path both returns msg and later calls node.send, or several promise branches send independently | Pick one completion model per task, and fix the control flow itself rather than deduplicating downstream |
| An async flow stalls, or nothing ever completes | Some path never calls node.done() | Confirm success, validation failure, and the catch path each call node.done() exactly once. “Complete” only means the monitored node finished, not that everything downstream did |
| Catch never receives the error | Missing msg on node.error, or the Catch node's scope does not include this Function | Use node.error(err,msg), confirm scope, and briefly enable a disabled Catch node only in an isolated test |
| Different branches see a mutated payload | Mutation after sending, reuse of the same nested object, or cloning skipped with node.send(msg,false) | Restore default cloning and build a separate payload object for each output message |
| Render Template returns nothing | It needs a live HA connection, and Home Assistant itself has to execute the Jinja template | Stop retries and any downstream side effects first, then verify the connection and the Catch path. Do not substitute Mustache or JSONata syntax as an experiment — see Chapter 18, Debugging and Testing, for detailed diagnosis |
| A transformation causes noticeable delays | A high-frequency input, or an expression that sorts data or scans every descendant on a large array | Disable the high-frequency input and reproduce with a small, fixed data set first. Measure the array length, then constrain the data before transforming it — do not just retry with larger test sets |
| The node runs or fails immediately after deployment | On Start or an external lib import failing during setup | Check On Start and external libs — the safe library requires both to be empty. Clear the lifecycle code, return to a pure manual Inject, then isolate the problem step by step rather than redeploying repeatedly |
| A context value changes after restart | The store's scope, name, initial value, or persistence setting does not do what you assumed | Confirm scope, named store, initial value, and persistence settings. Do not assume memory context survives a restart, or that every filesystem-store write becomes durable immediately |
The ones that come up again and again
Why not write msg.payload inside a JSONata expression?
payload directly. Function-node JavaScript is the one place msg.payload is correct, because there you are reading a real object property named msg.Can Mustache insert a whole object directly?
Do triple braces fix every escaping problem?
Can HA's JSONata helpers run inside a core Change node?
Does a synchronous Function need to call node.done manually?
node.done() once on every completion path only when the code has explicitly switched to the asynchronous node.send pattern.How do I send two messages out of the same output at once?
[[first, second], null]. Positions in the outer array are outputs; an inner array holds several messages destined for that one output.Is node.error(err,msg) the same as just returning an error object?
Can I build a cache in On Start?
Can a Function load any npm module directly?
functionExternalModules setting together with the node's own libs list — not by an unrestricted require. This chapter's examples keep libs empty throughout.Why is node.send(msg,false) discouraged?
What is the quickest way to distinguish Render Template from Template?
When should a transformation be rewritten as a Function?
When should I use Function instead of Change or JSONata?
Where to go from here
You can now pick the right tool for a transformation, and write a Function node that finishes cleanly every time.
Part 9 closes out this Part of the guide: reusable flow architecture — subflows, link nodes, and how to structure a growing set of flows so one change does not ripple everywhere — followed by a full pass through debugging and testing, including the Catch, Status, and Complete nodes this part only introduced in passing.
Open the full guidePart 8 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