Enable nodes to provide their own serialization/deserialization

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

  1. Properties returned by serialize() are extracted from the node config and stored as external files
  2. All other properties remain in flows.json using the default JSON serialization
  3. File properties are stored as { "path": "..." } objects in flows.json
  4. 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.json is 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 Serializable use 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

@knolleary @hardillb @dceejay @GogoVega could you take a look. I've updated the definition of the feature.

The main goals are:

  • avoid redefining the whole storage module/driver for specific properties/files. The storage module responsibility is for Read/Write files from a given storage, instead of defining how a prop is serialized/deserialized. It provides services to get the file when a property needs it. This module is a hack "node-red-contrib-js-storage", with all respect.
  • enable lazy loading of config properties that are buffers, and therefore save memory and speed up initilization. I've explained above how it can be done using getters while maintaining backward compatibility. In the future we can create a runtime garbage collector to release memory of properties, or even entire nodes, that have not been used for a while (warm/cold nodes/properties).
  • Keep the projects feature enabled while giving node developers a way to store certain node configs in separate files. The file change can be tracked using a hash property in the flows.json containing the hash value of the file.

This is how a node would provide custom serialize/desserialize methods during registration

RED.nodes.registerType(
    "my-node",
    function (config) {
        RED.nodes.createNode(this, config);
        this._node = new MyNode(RED, this, config, this.credentials);
    },
    {
        serialize: MyNode.serialize,
        desserialize: MyNode.desserialize
    }
);

I forgot to mention my use case. I have a node called salesforce-soql which allows users to query data from Salesforce. The default experience for its serialization is having its config.soql stored in a .soql file, not in flows.json. I dont want consumers to have to change their storage module just to use this node. If the node controls its serialization/deserialization, it will work without changing the storage module. The storage module will only drive where data is read/written from.

The lazy loading of config props that are files can be done the following way:

  • during boot, all files are downloaded by the storage module and stored locally, but not buffered. This download doesn't stop the startup, and can be done in parallel. If the file isn't available locally when using the getter for the first time, the getter will use the storage module to download it sync.
  • when needed, getters will fetch from the local storage and cache them in memory
  • a node prop usage tracker will be used to decide if the data should stay in memory. If the usage tracker detects the node hasn't been used for a while, all of its buffered data are set to null to release memory.

I have a use case to save memory so I can't keep all props that are files of all nodes in a flow buffered all the time. Also, buffering all of them increases startup time.

Nodes can already handle their own serialization/deserialization with oneditsave/oneditprepare (e.g. geofence node)

Breaking up the flow makes writing storage plugins harder, which can already save the different parts any way they choose.

Lazy loading just adds a bunch of first message latency that makes things more unpredictable.

If you are writing 100 x 5kb functions nodes you probably shouldn't be using Node-RED

That's editor-side only. It transforms data before saving to flows.json, but it still ends up as JSON in the same file. It doesn't solve:

  • External files for git workflows
  • Lazy loading for memory
  • Syntax highlighting in IDEs

True, but:

  • Local file read is ~1-5ms
  • Only on first message
  • Hot nodes stay in memory
    Trade-off is explicit and configurable

Scaling a flow using Lambda functions with N replicas + 100 x 5kb function nodes + Y x Zkb template nodes (static website with many pages) + W x Xkb salesforce soql nodes. There could exist many serialized data in flows.json that shouldn't be buffered unnecessarily. I think that if we consider scaling, the memory footprint would make sense when running in services that charge per memory and time, like AWS lambdas.

Fair point. But the proposal keeps flows.json as the source of truth - external files are optional and per-node. Storage plugins that don't care about files can simply ignore them.
More importantly, this proposal decouples how a node's data is loaded from where it's stored. Current solutions like node-red-contrib-js-storage couple file extraction with the storage plugin itself. This doesn't scale because different nodes have different config properties and mime types - a function node needs .js, a template node needs .html, a SQL node needs .sql.
The current approach also has practical issues:
User friction - I don't want to require users to install a custom storage plugin just to use my node
Unnecessary scope - Storage plugins must implement services I don't care about, like session and credentials storage
No reusability - Each node author solving this problem has to build their own storage plugin
With node-level serialization hooks, the node defines its own format, and any storage plugin can handle it transparently.

While I agree that over-reliance on Function nodes can be an anti-pattern, the suggestion to 'just write code' creates a new problem: loss of visibility. The value of Node-RED isn't just execution—it’s the ability to see the logic flow at a glance. If I move 500KB of logic into a single external script, I’ve essentially turned my flow into a 'black box,' and I lose the visual orchestration representation Node-RED provides

I found an issue. Sharing flows would be way harder. Today you can export everything as a single file. If this is ever implemented it would be necessary to create an archive exporter/importer in the client. Maybe I will create a git hook script that runs before commit and after pull since the only thing I need at the moment is ease versioning.

You can close this.