Overview
Node-RED stores all node configurations in a single flows.json file. This works well for simple data but becomes problematic for nodes containing code, templates, or binary assets. Large code blocks create unreadable diffs, making Git workflows difficult.
The Serialization API allows nodes to define which properties should be stored as external files while keeping the rest in flows.json.
How It Works
- Properties returned by
serialize()are extracted from the node config and stored as external files - All other properties remain in
flows.jsonusing the default JSON serialization - File properties are stored as
{ "path": "..." }objects in flows.json - On load, the runtime wraps file properties with lazy getters using the
deserialize()functions
Interface
interface Serializable {
/**
* Define which config properties should become external files.
*
* @param config - The node's full configuration
* @returns Object mapping property names to file definitions
*/
serialize(config: Record<string, any>): Record<string, {
content: Buffer;
mimeType: string; // e.g., '.js', '.sql', '.html'
} | undefined>;
deserialize(): Record<string, (buffer: Buffer) => any>;
}
Basic Example
class FunctionNode extends IONode<Config> implements Serializable {
static readonly type = "function";
static serialize(config: Record<string, any>): Record<string, { content: Buffer; extension: string } | undefined> {
return {
func: {
content: Buffer.from(config.func || '', 'utf8'),
mimeType: "application/js",
},
initialize: config.initialize ? {
content: Buffer.from(config.initialize, 'utf8'),
mimeType: "application/js",
} : undefined,
};
}
static deserialize(): Record<string, (buffer: Buffer) => any> {
return {
func: (buffer) => buffer.toString('utf8'),
initialize: (buffer) => buffer.toString('utf8'),
};
}
}
Before (everything in flows.json):
{
"id": "abc123",
"type": "function",
"name": "Transform Data",
"func": "const result = msg.payload.map(x => x * 2);\nmsg.payload = result;\nreturn msg;",
"outputs": 1,
"wires": [["def456"]]
}
After (code extracted to file):
{
"id": "abc123",
"type": "function",
"name": "Transform Data",
"func": { "path": "storage/abc123/func.js" },
"initialize": { "path": "nodes/abc123/initialize.js" },
"outputs": 1,
"wires": [["def456"]]
}
// storage/abc123/func.js
const result = msg.payload.map(x => x * 2);
msg.payload = result;
return msg;
Multiple Files Example
class FunctionNode extends IONode<Config> implements Serializable {
static readonly type = "function";
static serialize(config: Record<string, any>): Record<string, { content: Buffer; mimeType: string } | undefined> {
return {
func: {
mimeType: "text/javascript",
content: Buffer.from(config.func || '', 'utf8')
},
Initialize: {
mimeType: "text/javascript",
content: Buffer.from(config.initialize || '', 'utf8')
},
finalize: {
mimeType: "text/javascript",
content: Buffer.from(config.finalize || '', 'utf8')
},
};
}
static deserialize(): Record<string, (buffer: Buffer) => any> {
return {
func: (buffer) => buffer.toString('utf8'),
initialize: (buffer) => buffer.toString('utf8'),
finalize: (buffer) => buffer.toString('utf8'),
};
}
}
flows.json:
{
"id": "abc123",
"type": "function",
"name": "Transform Data",
"func": { "path": "storage/abc123/func.js" },
"initialize": { "path": "storage/abc123/initialize.js" },
"finalize": { "path": "storage/abc123/finalize.js" },
"outputs": 1,
"wires": [["def456"]]
}
Directory structure:
project/
├── flows.json
└── nodes/
└── abc123/
├── func.js
├── initialize.js
└── finalize.js
Binary Files Example
class WasmNode extends IONode<Config> implements Serializable {
static readonly type = "wasm-processor";
static serialize(config: Record<string, any>): Record<string, Buffer | undefined> {
return {
wasmBinary: config.wasmBinary ? {
mimeType: "application/wasm",
content: Buffer.from(config.wasmBinary, 'base64')
}: undefined,
glueCode: config.glueCode ? {
mimeType: "application/js",
content: Buffer.from(config.glueCode, 'utf8')
}: undefined,
};
}
static deserialize(): Record<string, (buffer: Buffer) => any> {
return {
wasmBinary: (buffer) => buffer.toString('base64'),
glueCode: (buffer) => buffer.toString('utf8'),
};
}
}
SQL Query Example
class SQLQueryNode extends IONode<Config> implements Serializable {
static readonly type = "sql-query";
static serialize(config: Record<string, any>): Record<string, Buffer | undefined> {
return {
query: {
mimeType: "application/sql",
content: Buffer.from(config.query || '', 'utf8'),
},
};
}
static deserialize(): Record<string, (buffer: Buffer) => any> {
return {
query: (buffer) => buffer.toString('utf8'),
};
}
}
flows.json:
{
"id": "def456",
"type": "sql-query",
"name": "Get Users",
"query": { "path": "storage/def456/query.sql" },
"connection": "postgres-config",
"wires": [["ghi789"]]
}
-- storage/def456/query.sql
SELECT
id,
name,
email,
created_at
FROM users
WHERE active = true
ORDER BY created_at DESC
LIMIT 100;
Lazy Loading
The runtime automatically wraps file properties with lazy getters. Files are not read from disk until the property is accessed:
const node = RED.nodes.getNode('abc123');
// No file I/O yet - these come from flows.json
console.log(node.name); // "Transform Data"
console.log(node.outputs); // 1
// File is read from disk on first access
console.log(node.config.func); // reads storage/abc123/func.js
This improves startup time for flows with many code-heavy nodes.
The runtime also caches the deserialized value, so subsequent accesses don't re-read the file.
Memory Usage
With default serialization, all node data is loaded into memory at startup - even if a node is never executed during the runtime's lifetime.
With custom serialization:
- At startup: Only
flows.jsonis loaded (small metadata) - At first access: File is read and deserialized into memory
- Never accessed: File is never loaded, memory is never allocated
This significantly reduces memory usage for flows with:
- Many function nodes with large code blocks
- Template nodes with HTML/Markdown content
- Nodes with embedded binary assets
- Disabled nodes that are kept for reference
For example, a flow with 100 function nodes averaging 5KB of code each:
| Approach | Memory at Startup |
|---|---|
| Default serialization | ~500KB (all code loaded) |
| Custom serialization | ~0KB (only loaded when executed) |
Nodes that are disabled, conditionally executed, or rarely triggered won't consume memory until they're actually needed.
Runtime Implementation
The runtime detects file references by their shape and replaces them with lazy getters.
Important: The config object passed to the node constructor function in registerType remains unchanged. File properties will contain getters instead of the raw data. When you access config.func, the getter is invoked, loading the file on demand. This is transparent to the node implementation.
// What the node receives
RED.nodes.registerType("function", function(config) {
// config.func is a getter, not the actual code
// Accessing it triggers file load:
console.log(config.func); // reads file, returns code string
// Second access returns cached value:
console.log(config.func); // no file I/O, returns cached string
});
What Stays in flows.json
Any property not returned by serialize() uses default JSON serialization:
static serialize(config: Record<string, any>): Record<string, Buffer | undefined> {
// Only 'func' becomes an external file
return {
func: {
mimeType: "application/js",
content: Buffer.from(config.func || '', 'utf8'),
},
};
}
Given this config:
{
id: "abc123",
type: "function",
name: "My Function",
func: "return msg;",
outputs: 1,
timeout: 5000,
wires: [["def456"]]
}
Result:
func→ external file, replaced with{ "path": "storage/abc123/func.js" }id,type,name,outputs,timeout,wires→ stay in flows.json as values
Returning undefined
Return undefined for optional properties that are empty:
static serialize(config: Record<string, any>): Record<string, Buffer | undefined> {
return {
func: {
mimeType: "application/js",
content: Buffer.from(config.func || '', 'utf8'),
},
// Only create file if initialize has content
initialize: {
mimeType: "application/js",
content: config.initialize
? Buffer.from(config.initialize, 'utf8')
: undefined,
}
};
}
If initialize is empty:
- No external file is created
- The property stays in flows.json as its original value (or is omitted)
Directory Structure
project/
├── flows.json # Node configs with file references
├── flows_cred.json # Credentials (encrypted)
└── storage/ # External node files
├── abc123/
│ ├── func.js
│ ├── initialize.js
│ └── finalize.js
├── def456/
│ └── query.sql
└── ghi789/
├── template.html
└── styles.css
File Reference Format
File properties are stored as objects with a path property:
{
"func": { "path": "storage/abc123/func.js" }
}
This format is:
- Distinguishable: Easy to differentiate from string values
- Extensible: Can add metadata later (e.g.,
encoding,hash,size)
{
"func": {
"path": "storage/abc123/func.js",
"encoding": "utf8",
"hash": "sha256:abc123..."
}
}
Benefits
| Aspect | Default Serialization | Custom Serialization |
|---|---|---|
| Git diffs | Unreadable JSON blobs | Clean code diffs |
| Code review | Near impossible | Normal workflow |
| Editor support | JSON only | Full IDE support |
| Memory usage | All loaded at startup | Lazy loading |
| Binary support | Base64 in JSON | Native files |
Backwards Compatibility
- Nodes without
Serializableuse the existing JSON serialization - Existing flows continue to work unchanged
- Migration can be done incrementally per node type
- The runtime can detect file references by their
{ "path": "..." }shape