Chapter 16

Function node: safe JavaScript and error handling

The Function node is well suited to processing messages with explicit JavaScript, routing them across multiple outputs, and raising catchable errors. Using only fixed manual input and data-only operations, this chapter defines the boundaries for synchronous return, asynchronous node.send/node.done, lifecycle hooks, context, observability, and cloning.

Why This Chapter Matters

Change, Switch, JSONata, and Template can handle most straightforward transformations. Use Function when a rule needs multiple outputs, explicit type guards, reusable helper functions, or consistent error messages. Because Function executes JavaScript, it is also more susceptible than declarative nodes to unbounded loops, shared state, duplicate sends, and asynchronous work that is difficult to trace.

This chapter is pinned to Node-RED 5.0.2. In this version, the core function-node group contains Function, Switch, Change, Range, Template, Delay, Trigger, Exec, and RBE; this chapter focuses only on the Function node's lifecycle and programming boundaries. The core Function runtime executes code in a sandbox, provides msg, node, and the context APIs, and handles synchronous return values and promises. A Function node is not an ordinary Node.js file: do not assume that unrestricted require is available, and never let message content decide which module to load. If you only need to construct a new object, revisit Chapter 15: JSONata and Templates. When a less powerful tool can do the job, prefer it over Function.

Safety boundaries for this chapter: Every program performs only bounded, deterministic, in-memory operations on input values. There are no environment settings, credentials, external modules, files, network operations, timers, Home Assistant queries, or actions. The safe flow library also requires the Function node's On Start, On Stop, and libs fields to remain empty.

One Message, Three Ways to Complete It

Node-RED 5.0.2 creates an execution task for every message that enters a Function node. You can return a result synchronously or call node.send after asynchronous work finishes. If the code explicitly uses node.done(), it is responsible for calling it on every completion path. Otherwise, the runtime handles completion after the Function result settles. Never return a message and later call node.send with the same result on the same path, or downstream nodes will receive it twice.

ModeOutputCompletionBest suited toCommon mistakes
Synchronous return msgReturns one message or an array arranged by outputThe runtime completes the task after the async function result resolvesPure transformations, immediate validation, and routingReturning a primitive; also using a delayed node.send
return nullNo outputEnds the synchronous pathExplicitly dropping or reporting invalid inputTreating null as a payload to send
node.send + node.doneSends a message when work finishesEvery success and failure path must call node.doneGenuinely asynchronous work that requires waitingOmitting or duplicating node.done; also returning a message
node.error(err,msg)Not a normal output; can pass the original message to a matching Catch nodeA synchronous path can then return null; an asynchronous path must still complete its taskRecoverable, observable input or operation errorsLogging only a string; omitting msg; including a sensitive payload in the error
Version 5.0.2 note: The runtime uses the AST to detect direct calls to node.done(). In an asynchronous Function, do not alias it or access it through a computed property. Call node.done() directly once on every completion path.

A Function node's output must be a message object. You may place a number in msg.payload, but you cannot directly return 42. The _msgid field is for message tracing; do not delete it or use it as a unique key for business data. For details of the input and message model, see Chapter 5: Messages and Data Types.

Start with a Fixed, Pure-Function Flow

10-pure-function.json contains only a manual Inject node, a Function node, and a Debug node. The example deliberately keeps the Function node's initialize and finalize fields as empty strings and its libs field as an empty array. Do not modify them to experiment with lifecycle hooks or modules.

  1. Read the flow JSON before importing it.

    Confirm that the Inject node's repeat and crontab fields are empty and once is false. The Function node must have only one output and no external modules or lifecycle code. The Debug node must display only payload and must not write to the console. If the file does not match this description, do not import it.

  2. Verify the data-only code.

    The pinned Function example accepts only a finite JavaScript number. After validation, it creates a new payload containing the original value and its square, then returns synchronously:

    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 neither stores state nor touches any external resource. For the manual input 6, the expected square is 36.

  3. Deploy on a separate tab and trigger it manually.

    First confirm that the flow has no wires to another tab or to nodes with side effects, then use the smallest necessary deployment scope. Press Inject once. In Debug, verify that the payload is an object, the original value is 6, and the square is 36.

  4. Test each type boundary separately.

    Invalid input must call node.error(err,msg) and return null. In an isolated temporary copy, add two manual Inject nodes (once=false, with repeat and crontab left empty). Have them write the strings nan and infinity, respectively, to msg.testCase. Connect both to a temporary Function node, then connect that node to the original “validate the value and calculate its square” Function. Use exactly this code in the temporary Function:

    if (msg.testCase === "nan") {
        msg.payload = Number.NaN;
    } else if (msg.testCase === "infinity") {
        msg.payload = Number.POSITIVE_INFINITY;
    } else {
        return null;
    }
    return msg;

    Also add a Catch node scoped only to the original Function under test, connected to a Debug node that displays only msg.error. The original Debug node must continue to display only msg.payload. Press the two Inject nodes manually, one at a time. For both inputs, the Catch output's msg.error.message must be payload-validation-failed, while the normal Debug node must receive no message. JSON cannot represent either numeric value directly, so the strings "NaN" and "Infinity" are not equivalent tests. Afterward, delete the temporary Inject, Function, Catch, and error Debug nodes. Do not change the downloaded example's strict contract; negative finite numbers remain valid.

  5. Connect the disabled diagnostic nodes.

    When you need to observe errors, status, or completion, also consult 11-debug-catch-status.json. Its Catch, Status, and Complete nodes are disabled by default. First confirm that each scope points only to the demonstration Function. On an isolated tab, briefly enable and test each node in turn, then disable it again.

Acceptance criteria: The same manual input produces the same output; invalid input produces no normal output; no startup code, cleanup code, modules, context, or external I/O are involved; and diagnostic output contains demonstration data only.

Synchronous Returns, Multiple Outputs, and Asynchronous Completion

Synchronous returns

The simplest pattern is to set fields on the original message and then return msg. If you create a new message object, copy only the business fields that downstream nodes explicitly require; do not spread the entire input into it. In 5.0.2, the runtime assigns the current input's _msgid to every valid output message. Directly modifying the original msg remains the simplest single-output pattern. The following example creates a new payload using a fixed number of operations:

if (typeof msg.payload !== "number" || !Number.isFinite(msg.payload)) {
    node.error(new Error("payload must be a finite number"), msg);
    return null;
}
const value = msg.payload;
msg.payload = { input: value, doubled: value * 2 };
return msg;

Multiple outputs and nested arrays

When a Function node is configured with two outputs, positions in the outer array correspond to output 1 and output 2. A null value means that nothing is sent through that output. This example sends 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 in sequence through the same output, place a nested array in that output's position. In the following example, 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];

In a two-output Function, [first, second] means one message for each output; [[first, second], null] means two messages for the first output and none for the second. Every non-null element must be a message object. Do not place a primitive where a message belongs or confuse a payload array with an output array.

Asynchronous node.send and node.done

The following example uses an already-resolved Promise to demonstrate the asynchronous control interface. The calculation is still an in-memory operation only, with no timer or external I/O. Because the code uses node.done(), both the success and failure paths complete explicitly. No message 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;

This example demonstrates only the API's shape. A synchronous squaring operation should use a synchronous return because there is no reason to add a Promise. Real asynchronous work needs explicit timeout, cancellation, and duplicate-send strategies, but this site's safe library does not include external-I/O examples.

Bounded, Readable, Testable JavaScript

A safe Function first narrows its input, then processes it, and finally constructs a fixed output shape. Do not copy the entire msg into long-lived context, and do not let dynamic strings become code, module names, or property paths for writes. For array input, define a maximum item count first; every loop must terminate at the bounded array length.

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 deliberately accepts arrays only, processes no more than 20 items, and outputs only index and value. Set production limits according to the device and message frequency, and record them in a Comment node or test contract. Do not recursively traverse objects of unknown depth, use an unbounded while loop, or convert a large Buffer into Debug text.

Cloning considerations

Messages can travel along multiple wires, so mutation and cloning must be deliberate. In Node-RED 5.0.2, a Function node's node.send clones the first message it sends by default. The API permits false as the second argument to skip that first clone, but this is appropriate only for exceptional data that is known to be uncloneable and whose full lifecycle is understood. Keep the default in ordinary flows; do not disable cloning for a negligible performance gain.

  • Do not mutate the same msg or a deeply nested child after sending it; other branches may observe unexpected content.
  • When creating two different messages, create two objects with separate payloads. Do not mutate, send, and then mutate the same reference again.
  • Do not pass live objects such as request/response objects, uncloneable objects, or huge Buffers through Function, Delay, or context. First extract only the data you actually need.
  • Cloning does not mask data. If a message contains sensitive fields, cloning only creates more copies; remove those fields before the message reaches Debug or storage.

Boundaries for external-module settings

The Function editor can list external modules, subject to the runtime's functionExternalModules setting. When external modules are explicitly prohibited, 5.0.2 rejects Function nodes that specify libs. When they are allowed, the runtime loads only the fixed modules listed by the node. This does not mean that the sandbox offers unrestricted require. The safe library requires libs to remain empty, does not demonstrate module code, and never allows a message to select a package. If a product truly needs a package, an administrator should pin its version, assess its supply-chain and licensing risks, and introduce it as a separate change—not smuggle it into a data transformation.

Give Errors, Logs, Status, and Catch Distinct Roles

node.error(err,msg) associates an error with its message so that a Catch node with matching scope can receive it. Calling only node.error(err) does not provide the same message association. For expected invalid input, create an Error that omits the original sensitive value, attach msg, and return null. In asynchronous work, the error path must still reach node.done.

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 enter the runtime logging pipeline; node.debug and node.trace depend on the configured log level. Never log the complete msg, a raw home event, identifying values, or credentials. Use node.status for short-lived, normal states that operators need to recognize quickly on the canvas. It is not a persistent monitor and does not automatically generate a Catch event.

ChannelAppropriate contentInappropriate contentCleanup or containment
Normal Function outputA normal message that meets the downstream contractAn error stack or raw untrusted inputUse a fixed shape and a separate error output
node.error(err,msg) + CatchA catchable failure and its associated messageEvery normal branch or secret contentLimit Catch scope to the specified Function
Runtime logShort, sanitized, aggregable diagnostic textA complete msg, token, environment identifier, or large objectRemove temporary logging after troubleshooting and restore the required level
node.status + StatusA transient node state and scoped status eventsA business database, guarantee of success, or long textClear with an empty status after completion; limit the Status node's scope
DebugSelected fields during manual testingA long-running stream of complete messagesLimit fields and frequency; disable after testing

In 11-debug-catch-status.json, the Catch, Status, and Complete nodes are explicitly scoped to the demonstration Function and disabled by default. The example provides three manual Inject nodes that trigger normal output, a test error, and bounded status, respectively. Four separate Debug nodes display only msg.payload, msg.error, msg.status, or msg.complete. Enable observers only briefly and only in an isolated copy. For more on the differences among Catch, Status, and Complete, see Chapter 18: Debugging and Testing.

Lifecycle, Context, and Maintenance Boundaries

On Start and On Stop

In Node-RED 5.0.2, the Function editor provides On Start (stored in the underlying initialize field) and On Stop (finalize). On Start may be asynchronous: the runtime waits for modules and the initialization Promise before processing queued messages. If initialization fails, the node logs an error and does not begin normal processing. On Stop runs when the node closes. The runtime then cleans up any outstanding timers created in the sandbox; 5.0.2 does not allow the close function to send messages.

The existence of a capability does not mean that this tutorial needs it. This chapter's downloadable examples deliberately leave initialize and finalize blank because a deployment or restart can execute startup code, unlike a manual Inject. Represent required prerequisite data as explicit messages so that each run is reproducible; this chapter provides no lifecycle code. If an existing flow already uses lifecycle hooks, understand its startup, failure, cleanup, and repeated-execution behavior before deployment. Do not treat lifecycle code as ordinary function annotations.

Node, flow, and global context

A Function node can use the context, flow, and global get/set APIs for different scopes and, when configured by the runtime, can select a named store. Context is appropriate for a small amount of flow state with a clearly defined lifecycle—not for a secret vault, task queue, or unbounded history. Asynchronous stores may require callback forms; do not assume that every store supports synchronous reads and writes. For complete storage boundaries, see Chapter 7: Time and Context.

  • If it can be derived from msg, do not store it: A pure Function is easiest to test and is unaffected by state left over from a deployment or restart.
  • If it must be stored, fix the key and shape: Define the initial value, maximum size, update atomicity, clearing time, and store together.
  • Do not write a large object for every message: Writes and flushes to persistent stores have a cost, and not every assignment becomes durable immediately.
  • Do not store secrets or complete HA objects: Debug output, exports, or backups may indirectly expose context.

Treat Function as a testable transformation unit

Name the node for its input-to-output behavior—for example, “validate the value and calculate its square”—rather than “process data.” In the node's information, document the input shape, output count, error branch, and limits. If the code exceeds one screen, first split it into small pure helper functions or multiple nodes. Do not cram routing, storage, networking, and presentation into a single Function. For every change, use fixed Inject nodes to test normal values, boundary values, incorrect types, and null values before connecting the Function to its real downstream nodes.

Troubleshooting

  • Function reports “non-message returned”: Inspect every non-null element passed to return or node.send. Each must be a message object. Put numbers, strings, or array data in msg.payload; do not send them directly as messages.
  • The second output receives no message: Confirm that the Function node is configured with 2 outputs and that the outer array is [firstOutput, secondOutput]. A nested array in the first position means multiple messages for the first output, not two outputs.
  • Messages appear more than once: Check whether the same path both returns msg and later calls node.send, or whether multiple Promise branches send. Choose one output model per task. Correct the control flow before adding a completion flag, and do not conceal the problem with downstream deduplication.
  • An asynchronous flow stalls, or Complete never arrives: Confirm that successful processing, validation failure, and catch each call node.done exactly once. Define timeout and cancellation policies; this chapter's examples perform no external waits. Complete means only that the monitored node has finished, not that every downstream operation has finished.
  • Catch does not receive node.error: Use node.error(err,msg) and confirm that the Catch scope includes the Function. If the Catch node is disabled, briefly enable it in an isolated test. Do not replace it with logging of the complete msg.
  • Different branches see a mutated payload: Check for mutation after sending, reuse of the same deeply nested object, or use of the skip-cloning option. Restore default cloning and create a separate payload for each output message.
  • The node runs or fails immediately after deployment: Check On Start and external libs. The safe library requires both to be empty; do not keep redeploying in the hope that the issue resolves. First clear the lifecycle code, return to a pure manual Inject, and then isolate the problem step by step.
  • A context value changes after restart: Confirm its scope, named store, initial value, and persistence settings. Do not assume that memory context survives a restart or that every set to the filesystem store becomes durable immediately.

Pinned sources and official documents

This chapter derives version-specific facts only from the exact Node-RED commit approved for Chapter 16; official documentation supplements operational concepts. If the continuously updated documentation describes APIs absent from the pinned version, the 5.0.2 source code takes precedence.

FAQ

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.
Does a synchronous Function need to call node.done manually?
A normal synchronous return does not require node.done. Version 5.0.2 handles completion after the Function result settles. Call node.done once on every completion path only when the code explicitly uses the asynchronous node.send pattern with node.done.
How do I send two messages through the first output at once?
Place a nested array in the first position of the outer array—for example, [[first, second], null]. Positions in the outer array represent outputs; the inner array holds multiple messages for one output.
Is node.error(err,msg) the same as returning an error object?
No. node.error lets a matching Catch node receive the error and its associated message; a returned object is simply a normal output. Keep error paths distinct and omit sensitive original values.
Can I build a cache in On Start?
Node-RED 5.0.2 supports On Start, but this site's safe flow library prohibits startup code because deployment or restart can execute it. Prefer explicit messages that establish reproducible state; assess existing lifecycle code separately.
Can I load any npm module directly in a Function?
Do not assume so. External modules are controlled by the functionExternalModules setting and the node's libs list, not by unrestricted require. Both this chapter and the safe flow leave libs empty.
Why is node.send(msg,false) discouraged?
The second argument, false, skips the default clone of the first message. Mutation after sending may then affect other paths. Keep the default unless you are handling a known uncloneable object and fully understand its lifecycle.