Skip to Content

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

four grammars, one message
Node-RED Guide · Part 8

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.

4
Engines that all use braces, parentheses, or dollar signs — JSONata, Mustache, Function JavaScript, and HA Jinja — and share none of the same syntax, types, or execution location
payload, not msg.payload
The single most common JSONata mistake in this chapter. Node-RED hands JSONata the whole message as its root document already
4
Ways a Function node can finish handling one message: return msg, return null, node.send plus node.done, or node.error to a Catch node
Why this chapter matters

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.

Safety boundaries in this chapter. Every runnable example uses only a manual Inject node, a Change or Function node, and a Debug node. Nothing here reads environment settings, credentials, files, the network, or a live Home Assistant entity, and nothing calls a service or action. Every one of them can be read and understood in an offline editor with no Home Assistant connection at all.
What 5.0.2 and 0.80.3 also cover. Node-RED 5.0.2's core function-node group additionally includes Range, Delay, Trigger, Exec, RBE, and the JSON, CSV, HTML, XML, and YAML parser nodes. HA WebSocket nodes 0.80.3 is the version this chapter's Home Assistant helper functions are pinned to. Exec runs external programs and must never be reached for as a shortcut around ordinary data conversion — and whichever parser you use, you still bound the input's size and shape before it touches the parser at all.
Four engines, one syntax look

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"]
Three land together, one crosses outFour branches leave the decision diamond, one per engine. Three of them — JSONata, Mustache, and JavaScript — converge on the same box, because all three execute inside Node-RED. Only the Render Template branch ends in a separate box that names Home Assistant, because that one request has to leave Node-RED and come back before it has a result.
EnvironmentWhere it runsInput root and result type
JSONataInside Node-RED, wherever a typed input is set to expression — typically Change or SwitchThe 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
MustacheThe core Template nodeLooks 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 JavaScriptThe Function node's own sandboxReads 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 JinjaRendered by Home Assistant itself, after the Render Template node sends it thereUses 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

RequirementPreferred toolStop condition — move up a level
Set, move, or delete one propertyChangeAdd a Switch first if the input shape has not been validated yet
Compute a new object from a fixed JSON structureJSONataMove to a Function node once it scans an unbounded collection or gets hard to read at a glance
Compose a short notification or explanatory lineMustache TemplateChoose another tool the moment you need to insert a whole object or produce a non-string value
Produce multiple outputs, explicit errors, or real branchingFunction JavaScriptDo 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 semanticsHA Jinja via Render TemplateDo not reach for it when the work must stay offline, low-latency, or confined to Node-RED alone
Turn external text into a structureThe matching core parser — JSON, CSV, HTML, XML, YAMLDo 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.

JSONata

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"]
Three inputs, three different shapes of nothingThree branches leave the diamond and each ends in its own box. The middle and right-hand boxes both look like “failure,” but they are not the same value — an object with missing fields is not the same as no result whatsoever, and a downstream Switch has to treat them as two separate cases, not one.
In plain terms

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.

Acceptance conditions for this flow. Only you trigger it, and only manually. Debug shows local demonstration values only. The output is always an object. Nothing here creates context or reads an environment setting, and nothing has an external side effect.

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.

HelperWhat it does in 0.80.3Usage boundary
$entity()The entity object that triggered the current nodeNot every node or event has one; check for undefined first
$prevEntity()The previous-state entity, from an event nodeMay not exist during initialization, creation, or deletion
$entities() / $entities(entity_id)Every cached entity, or one chosen by entity IDThe 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 lookupAreas and IDs are specific to your own home; a result may not exist
$areaDevices(areaId) / $areaEntities(areaId)Devices or entities tied to one areaConstrain 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 entitiesNames 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 availableAvailable 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.3Non-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.

Mustache

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.

In plain terms

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.

The safe default. Use Mustache, with its default escaping left on, for short human-readable text. When a number, Boolean, array, or object has to keep its type, reach for JSONata or another typed input on a Change node instead — do not turn a value into text and then guess at its type on the other side.
Function node

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.

Safety boundaries for this section. Every example here performs only bounded, deterministic, in-memory work on values already in the message. None of them touch environment settings, credentials, external modules, files, the network, timers, Home Assistant queries, or actions, and every example keeps the Function node's On Start, On Stop, and libs fields empty.
ModeOutputBest suited to, and the common mistake
Synchronous return msgOne message, or an array arranged by outputPure 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 nullNo output at allExplicitly dropping invalid input. Mistake: treating null as a payload worth sending
node.send + node.doneSends a message once the work finishesGenuinely 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 insteadRecoverable, observable failures. Mistake: logging only a string with no msg attached, or putting a sensitive payload inside the error
Version 5.0.2 note. The runtime inspects the code's AST to detect a direct call to 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"]
Four branches, one per row aboveEvery branch off the diamond ends in its own box, matching the four rows in the completion table. Only the node.send branch carries a counting rule in its own label — node.done() exactly once — because that is the single mode Node-RED cannot infer the end of on its own.
In plain terms

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.

Acceptance criteria. The same manual input always produces the same output. Invalid input produces no normal output at all. Nothing here touches startup code, cleanup code, modules, context, or anything outside the flow. Diagnostic output contains demonstration data only.
Bounded, safe JavaScript

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"]
Only the third shape nests an arrayThree branches, three different outer arrays. The first two put a message object directly in a position. Only the third puts an array inside a position, and that inner nesting is the entire difference between “one output receives an array as its payload” and “one output receives two separate messages.”

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.

Errors, logs, and lifecycle

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.

ChannelBelongs hereDoes not belong here
Normal Function outputA message that meets the downstream contractAn error stack, or raw untrusted input
node.error(err,msg) + CatchA catchable failure and the message tied to itA normal branch, or anything sensitive
Runtime logShort, sanitized, aggregable textA full msg, a token, an environment identifier, or a large object
node.statusA transient node state, and scoped Status eventsA business database, a guarantee of success, or long text
DebugSelected fields during manual testingA 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.

In plain terms

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.

Troubleshooting

The failures that actually happen, and where to look first

SymptomLikely causeWhat to check
JSONata shows no resultThe root path is msg.payload instead of payload, or the path or index is wrongConfirm 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 NaNThe Inject payload type does not match what the expression assumesA 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 outputDefault double-brace escaping, not corrupted dataConfirm 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 parseUnescaped dynamic text inserted directly into the templateReduce 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 nodeThose helpers exist only in HA nodes' own JSONata serviceRestructure to use plain message input, or move the helper into a supported HA node field
Function reports a non-message returnedA number, string, or array was returned where a message object belongedCheck 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 anythingThe outer array shape does not match the node's output countConfirm 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 onceThe same path both returns msg and later calls node.send, or several promise branches send independentlyPick one completion model per task, and fix the control flow itself rather than deduplicating downstream
An async flow stalls, or nothing ever completesSome 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 errorMissing msg on node.error, or the Catch node's scope does not include this FunctionUse node.error(err,msg), confirm scope, and briefly enable a disabled Catch node only in an isolated test
Different branches see a mutated payloadMutation 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 nothingIt needs a live HA connection, and Home Assistant itself has to execute the Jinja templateStop 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 delaysA high-frequency input, or an expression that sorts data or scans every descendant on a large arrayDisable 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 deploymentOn Start or an external lib import failing during setupCheck 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 restartThe store's scope, name, initial value, or persistence setting does not do what you assumedConfirm 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
Questions people ask

The ones that come up again and again

Why not write msg.payload inside a JSONata expression?
Node-RED already hands JSONata the whole message as its root document, so the field is reached as 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?
Mustache exists to render text. Interpolating an object usually produces a string that is not fit for downstream use. When an object or array has to keep its type, build it with JSONata or a Function node instead.
Do triple braces fix every escaping problem?
No — they turn off Mustache's default escaping and can let untrusted content reach HTML as markup. Choose the tool for the actual output context, and do not assemble object data through raw text interpolation regardless of which brace style you use.
Can HA's JSONata helpers run inside a core Change node?
Not by default. In 0.80.3 those helpers are injected into the JSONata service used specifically by Home Assistant nodes. A plain Change node only has standard JSONata and the message itself to work with.
Does a synchronous Function need to call node.done manually?
No. A plain synchronous return does not need it — the 5.0.2 runtime completes the task once the returned result settles. Call 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?
Nest an array in that output's position — for example [[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?
No. node.error lets a matching Catch node receive the error together with the message that caused it; a returned object is only ever a normal output. Keep the two paths distinct, and never put the raw original value into the error itself.
Can I build a cache in On Start?
Node-RED 5.0.2 supports it, but this chapter's safe examples prohibit startup code on purpose, because a deployment or restart executes it in a way a manual Inject never does. Prefer explicit messages that establish reproducible state, and review any existing lifecycle code on its own terms before relying on it.
Can a Function load any npm module directly?
Not by assumption. External modules are gated by the runtime's 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?
That second argument skips the default clone of the first message sent, so any mutation afterward can affect other branches unexpectedly. Keep the default unless you are handling a specific, known-uncloneable object whose entire lifecycle you already understand.
What is the quickest way to distinguish Render Template from Template?
The core Template node executes Mustache in Node-RED. Render Template sends Jinja to Home Assistant for execution and receives a string. They differ in execution location, context, and failure modes.
When should a transformation be rewritten as a Function?
Use a Function node when the expression requires unreadable layers of branching, complex exceptional cases, explicit multiple outputs, or reusable procedures. Continue to bound the input and keep the operation purely data-oriented.
When should I use Function instead of Change or JSONata?
Use Function when you need multiple outputs, an explicit error path, or more complex but bounded control structures. For setting a single field or performing a straightforward object mapping, prefer a less powerful node.
Next

Where to go from here

from one node to a system of them

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 guide

Part 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

Every intent starts as a proposal, and the Action node stays off until you turn it on yourself