A Function-Node Pattern for Safe MQTT Payload Transformation in Node-RED
A practical function-node pattern for Node-RED that validates MQTT payloads defensively, normalizes output, reports health via node status, and persists state across restarts — plus the event-loop limits you need to respect.
27 Jun 2026, 08:30 UTC

The problem: raw MQTT payloads are fragile
If you wire an MQTT-in node straight into a dashboard gauge, everything works until it doesn't. One device sends a string instead of a number, another publishes malformed JSON during a firmware update, and suddenly your flow throws uncaught exceptions or your chart shows garbage. The fix isn't more nodes — it's a single, disciplined function node that validates, transforms, and reports status before anything downstream sees the data.
The pattern below does three things: parses defensively, normalizes the payload into a predictable shape, and exposes a status indicator so you can see transformation health in the editor without opening the debug sidebar.
Defensive parsing with a safe fallback
JSON.parse throws on invalid input, and an uncaught throw in a function node kills that message and logs an error. Wrap it, and default to a structure your downstream nodes can tolerate:
// Runs inside a Node-RED function node (Node.js sandbox)
let raw = msg.payload;
let data;
try {
data = (typeof raw === 'string') ? JSON.parse(raw) : raw;
} catch (err) {
data = {};
node.warn('Unparseable payload from ' + msg.topic);
}
// Validate expected fields before touching them
const temp = (typeof data.temperature === 'number') ? data.temperature : null;
const hum = (typeof data.humidity === 'number') ? data.humidity : null;
if (temp === null) {
node.status({fill:'red', shape:'ring', text:'bad payload'});
return null; // drop the message; nothing downstream fires
}
msg.payload = {
temperature: Math.round(temp * 10) / 10,
humidity: hum,
device: msg.topic.split('/')[1] || 'unknown',
ts: Date.now()
};
node.status({fill:'green', shape:'dot', text: temp.toFixed(1) + ' °C'});
return msg;Two details matter here. First, the typeof checks happen before arithmetic — MQTT brokers deliver payloads as strings or Buffers depending on your MQTT-in node configuration, so never assume a type. Second, returning null drops bad messages cleanly instead of propagating a half-built object that breaks a chart node three steps later.
Status as a debugging tool, not decoration
node.status() shows text under the node in the editor. Used well, it's a zero-cost health dashboard: green dot with the last good reading, red ring on validation failure, yellow ring if no message has arrived within an expected window (you can track that with a timestamp in context). This beats a debug node for production flows because it doesn't flood the sidebar and it's visible at a glance when someone opens the editor months later.
Keeping state across restarts with flow context
Sometimes the transformation needs memory — for example, reporting a delta from the previous reading or suppressing duplicate values. Flow-scoped context handles this without an external database:
const prev = flow.get('lastTemp') || null;
flow.set('lastTemp', temp);
msg.payload.delta = (prev !== null) ? +(temp - prev).toFixed(1) : 0;By default, context lives in memory and vanishes on restart. To persist it, enable the localfilesystem context store in settings.js (found in your Node-RED user directory, typically ~/.node-red):
contextStorage: {
default: { module: 'localfilesystem' }
}Edit this with Node-RED stopped, then restart. To verify persistence works: set a flow value via an inject node, restart Node-RED, and read the value back with a second inject wired to a debug node. If the value is gone, check that the user directory path is writable — a misconfigured or read-only path silently falls back to memory-only behavior. This requires write access to the settings file and a Node-RED restart, so plan it for a maintenance window on anything production.
The trade-off: the event loop is shared
Everything in a function node runs on Node-RED's single event loop. String parsing and field validation are fine — microseconds each. But if your transformation grows into heavy computation (large array processing, regex over big payloads, cryptographic work), you'll block every other flow in the instance. The symptom is laggy editor response and delayed messages across unrelated flows. The honest limit of this pattern: keep function nodes to validation, reshaping, and light arithmetic. Anything heavier belongs in an external service reached via an HTTP request node, or a dedicated Node.js process communicating over MQTT itself.
Try it and check the result
Wire an inject node (set its payload to a JSON string like {"temperature": 21.34, "humidity": 45}) into the function node, and a debug node on the output. Deploy, inject, and confirm three things: the debug sidebar shows the normalized object, the node status shows the green temperature readout, and injecting deliberately broken JSON ({temp:) produces a red status ring and a warning — not an exception. That last check is the one people skip, and it's the one that matters at 2 a.m. when a device misbehaves.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.