# Enable nodes to provide their own serialization/deserialization

**URL:** <https://discourse.nodered.org/t/enable-nodes-to-provide-their-own-serialization-deserialization/100425>\
**Category:** Feature Requests\
**Created:** [21 February 2026 00:16 UTC](https://discourse.nodered.org/t/enable-nodes-to-provide-their-own-serialization-deserialization/100425 "2026-02-21T00:16:28Z")\
**Posts on this page:** 8\
**Page:** 1

<div class="post-metadata">

**Author:** ![AllanOricil](https://sea2.discourse-cdn.com/flex026/user_avatar/discourse.nodered.org/allanoricil/32/106911_2.png) [@AllanOricil](https://discourse.nodered.org/u/AllanOricil)\
**Post date:** [21 February 2026 00:16 UTC](https://discourse.nodered.org/t/enable-nodes-to-provide-their-own-serialization-deserialization/100425/1 "2026-02-21T00:16:28Z")

</div>

## 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

```typescript
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

```typescript
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):

```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):

```json
{
  "id": "abc123",
  "type": "function",
  "name": "Transform Data",
  "func": { "path": "storage/abc123/func.js" },
  "initialize": { "path": "nodes/abc123/initialize.js" },
  "outputs": 1,
  "wires": [["def456"]]
}

```

```javascript
// storage/abc123/func.js
const result = msg.payload.map(x => x * 2);
msg.payload = result;
return msg;

```

## Multiple Files Example

```typescript
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:

```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:

```auto
project/
├── flows.json
└── nodes/
    └── abc123/
        ├── func.js
        ├── initialize.js
        └── finalize.js

```

## Binary Files Example

```typescript
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

```typescript
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:

```json
{
  "id": "def456",
  "type": "sql-query",
  "name": "Get Users",
  "query": { "path": "storage/def456/query.sql" },
  "connection": "postgres-config",
  "wires": [["ghi789"]]
}

```

```sql
-- 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:

```typescript
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.

```typescript
// 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:

```typescript
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:

```typescript
{
  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:

```typescript
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

```auto
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:

```json
{
  "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`)

```json
{
  "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

---

<div class="post-metadata">

**Author:** ![AllanOricil](https://sea2.discourse-cdn.com/flex026/user_avatar/discourse.nodered.org/allanoricil/32/106911_2.png) [@AllanOricil](https://discourse.nodered.org/u/AllanOricil)\
**Post date:** [21 February 2026 03:11 UTC](https://discourse.nodered.org/t/enable-nodes-to-provide-their-own-serialization-deserialization/100425/2 "2026-02-21T03:11:11Z")

</div>

@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

```js
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
    }
);

```

---

<div class="post-metadata">

**Author:** ![AllanOricil](https://sea2.discourse-cdn.com/flex026/user_avatar/discourse.nodered.org/allanoricil/32/106911_2.png) [@AllanOricil](https://discourse.nodered.org/u/AllanOricil)\
**Post date:** [21 February 2026 14:19 UTC](https://discourse.nodered.org/t/enable-nodes-to-provide-their-own-serialization-deserialization/100425/3 "2026-02-21T14:19:58Z")

</div>

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.

---

<div class="post-metadata">

**Author:** ![AllanOricil](https://sea2.discourse-cdn.com/flex026/user_avatar/discourse.nodered.org/allanoricil/32/106911_2.png) [@AllanOricil](https://discourse.nodered.org/u/AllanOricil)\
**Post date:** [21 February 2026 15:23 UTC](https://discourse.nodered.org/t/enable-nodes-to-provide-their-own-serialization-deserialization/100425/4 "2026-02-21T15:23:36Z")

</div>

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.

---

<div class="post-metadata">

**Author:** ![hardillb](https://sea2.discourse-cdn.com/flex026/user_avatar/discourse.nodered.org/hardillb/32/12373_2.png) [@hardillb](https://discourse.nodered.org/u/hardillb)\
**Post date:** [21 February 2026 16:28 UTC](https://discourse.nodered.org/t/enable-nodes-to-provide-their-own-serialization-deserialization/100425/5 "2026-02-21T16:28:26Z")

</div>

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

---

<div class="post-metadata">

**Author:** ![AllanOricil](https://sea2.discourse-cdn.com/flex026/user_avatar/discourse.nodered.org/allanoricil/32/106911_2.png) [@AllanOricil](https://discourse.nodered.org/u/AllanOricil)\
**Post date:** [21 February 2026 16:58 UTC](https://discourse.nodered.org/t/enable-nodes-to-provide-their-own-serialization-deserialization/100425/6 "2026-02-21T16:58:24Z")

</div>

> [@hardillb](#):
>
> Nodes can already handle their own serialization/deserialization with oneditsave/oneditprepare (e.g. geofence node)

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

> [@hardillb](#):
>
> Lazy loading just adds a bunch of first message latency that makes things more unpredictable.

True, but:

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

> [@hardillb](#):
>
> If you are writing 100 x 5kb functions nodes you probably shouldn't be using Node-RED

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.

> [@hardillb](#):
>
> Breaking up the flow makes writing storage plugins harder, which can already save the different parts any way they choose.

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.

---

<div class="post-metadata">

**Author:** ![AllanOricil](https://sea2.discourse-cdn.com/flex026/user_avatar/discourse.nodered.org/allanoricil/32/106911_2.png) [@AllanOricil](https://discourse.nodered.org/u/AllanOricil)\
**Post date:** [21 February 2026 17:11 UTC](https://discourse.nodered.org/t/enable-nodes-to-provide-their-own-serialization-deserialization/100425/7 "2026-02-21T17:11:54Z")

</div>

> [@hardillb](#):
>
> If you are writing 100 x 5kb functions nodes you probably shouldn't be using Node-RED

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

---

<div class="post-metadata">

**Author:** ![AllanOricil](https://sea2.discourse-cdn.com/flex026/user_avatar/discourse.nodered.org/allanoricil/32/106911_2.png) [@AllanOricil](https://discourse.nodered.org/u/AllanOricil)\
**Post date:** [21 February 2026 18:51 UTC](https://discourse.nodered.org/t/enable-nodes-to-provide-their-own-serialization-deserialization/100425/8 "2026-02-21T18:51:16Z")

</div>

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.
