<figure class="wp-block-image aligncenter"><img src="https://flowgenius.in/wp-content/uploads/2026/01/custom-node-performance.png" alt="Step by Step Guide to solve custom node performance" /><figcaption style="text-align: center;">Step by Step Guide to solve custom node performance</p>
<hr />
</figcaption></figure>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Who this is for:</strong> n8n developers building custom nodes that must handle thousands of items in production‑grade workflows. <strong>We cover this in detail in the </strong><a href="https://flowgenius.in/n8n-performance-and-scaling-guide/">n8n Performance & Scaling Guide.</a></p>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">Quick Diagnosis</h2>
<p style="margin-bottom: 2em; line-height: 1.9;">Your custom n8n node is slowing down the entire workflow because it processes data synchronously, allocates large objects in memory, or makes blocking I/O calls. The fastest fix for a featured‑snippet‑ready answer is: <strong>refactor the node to use asynchronous streams, limit in‑memory payload size, and benchmark with the built‑in profiler</strong>.</p>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">1. Execution Model Overview</h2>
<p>If you encounter any <a href="/docker-performance-tuning">docker performance tuning </a>resolve them before continuing with the setup.</p>
<table style="border-collapse: collapse; width: auto; margin-bottom: 2em;">
<thead>
<tr>
<th style="padding: 13px; border: 1px solid #ddd;">Phase</th>
<th style="padding: 13px; border: 1px solid #ddd;">What n8n does</th>
<th style="padding: 13px; border: 1px solid #ddd;">Typical Pitfall</th>
<th style="padding: 13px; border: 1px solid #ddd;">EEFA Note</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;"><strong>Input</strong></td>
<td style="padding: 13px; border: 1px solid #ddd;"><code>this.getInputData()</code> returns an array of items.</td>
<td style="padding: 13px; border: 1px solid #ddd;">Pulling the whole array into a temporary variable and looping synchronously.</td>
<td style="padding: 13px; border: 1px solid #ddd;">Large batches (>10 k items) can exceed the V8 heap → “JavaScript heap out of memory”.</td>
</tr>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;"><strong>Processing</strong></td>
<td style="padding: 13px; border: 1px solid #ddd;"><code>execute()</code> runs for each item (or once for the whole batch).</td>
<td style="padding: 13px; border: 1px solid #ddd;">Heavy CPU work (e.g., JSON parsing, regex) inside the loop.</td>
<td style="padding: 13px; border: 1px solid #ddd;">Offload CPU‑intensive work to a worker thread or external service.</td>
</tr>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;"><strong>Output</strong></td>
<td style="padding: 13px; border: 1px solid #ddd;"><code>return this.prepareOutputData(items)</code> pushes results downstream.</td>
<td style="padding: 13px; border: 1px solid #ddd;">Returning a massive array without pagination or streaming.</td>
<td style="padding: 13px; border: 1px solid #ddd;">Use <code>this.helpers.returnJsonArray()</code> only for ≤ 5 k items; otherwise stream.</td>
</tr>
</tbody>
</table>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Key takeaway:</strong> n8n expects nodes to be <em>non‑blocking</em> and <em>memory‑conservative</em>. Any deviation creates a bottleneck that propagates downstream.</p>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">2. Refactor Synchronous Loops into Asynchronous Streams</h2>
<h3 style="margin-bottom: 45px; line-height: 1.3;">2.1 Bad Example – Blocking Loop</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">The node pulls all items, processes each synchronously, and returns a huge array.</p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">// Get items and initialise results
const items = this.getInputData();
const results = [];
// Process each item synchronously
for (const item of items) {
const processed = heavySyncFunction(item.json);
results.push({ json: processed });
}
// Return the full result set
return this.prepareOutputData(results);
</pre>
<h3 style="margin-bottom: 45px; line-height: 1.3;">2.2 Good Example – Parallel Async Processing</h3>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Step 1 – Worker helper (4 lines).</strong> Runs heavy work in a separate thread and resolves a promise.</p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">import { Worker } from 'worker_threads';
function runInWorker(data: any): Promise {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', { workerData: data });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', code => {
if (code !== 0) reject(new Error(`Worker stopped with code ${code}`));
});
});
}
</pre>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Step 2 – Execute with limited concurrency (5 lines).</strong></p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">export async function execute(this: IExecuteFunctions) {
const items = this.getInputData();
const concurrency = Math.min(8, items.length);
const results: any[] = [];
for (let i = 0; i < items.length; i += concurrency) { const chunk = await Promise.all( items.slice(i, i + concurrency).map(item => runInWorker(item.json))
);
results.push(...chunk);
}
return this.prepareOutputData(results.map(r => ({ json: r })));
}
</pre>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Why it works:</strong><br />
Each item runs in its own thread, freeing the main event loop.<br />
Concurrency is capped (here at 8) to avoid CPU thrashing—critical on shared‑CPU containers.</p>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>EEFA warning:</strong> Worker threads increase memory usage per thread. Monitor the container’s memory (`docker stats` or `kubectl top pod`) and adjust <code>concurrency</code> accordingly. If you encounter any <a href="/cpu-profiling">cpu profiling </a>resolve them before continuing with the setup.</p>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">3. Reduce In‑Memory Payload Size</h2>
<h3 style="margin-bottom: 45px; line-height: 1.3;">3.1 Trim Unnecessary Fields</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">Keep only the data you actually need before passing it downstream.</p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">function prune(item: any) {
const { id, name, status } = item; // keep only what you need
return { id, name, status };
}
</pre>
<h3 style="margin-bottom: 45px; line-height: 1.3;">3.2 Use Chunked Processing</h3>
<table style="border-collapse: collapse; width: auto; margin-bottom: 2em;">
<thead>
<tr>
<th style="padding: 13px; border: 1px solid #ddd;">Batch size</th>
<th style="padding: 13px; border: 1px solid #ddd;">Avg. latency (ms)</th>
<th style="padding: 13px; border: 1px solid #ddd;">Memory (MiB)</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;">500 items</td>
<td style="padding: 13px; border: 1px solid #ddd;">120</td>
<td style="padding: 13px; border: 1px solid #ddd;">45</td>
</tr>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;">2 000 items</td>
<td style="padding: 13px; border: 1px solid #ddd;">460</td>
<td style="padding: 13px; border: 1px solid #ddd;">180</td>
</tr>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;">5 000 items</td>
<td style="padding: 13px; border: 1px solid #ddd;">1 200</td>
<td style="padding: 13px; border: 1px solid #ddd;">420</td>
</tr>
</tbody>
</table>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Recommendation:</strong> Keep batch size ≤ 2 000 for typical 2 GiB containers. For larger volumes, split the workflow with a “SplitInBatches” node before your custom node. If you encounter any <a href="/resource-limiting-with-cgroups">resource limiting with cgroups </a>resolve them before continuing with the setup.</p>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">4. Leverage n8n’s Built‑In Profiling Tools</h2>
<h3 style="margin-bottom: 45px; line-height: 1.3;">4.1 Enable Debug Logging</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">Add this to <code>~/.n8n/config</code> to capture detailed execution timestamps.</p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">execution:
logLevel: debug
</pre>
<h3 style="margin-bottom: 45px; line-height: 1.3;">4.2 Log Node Duration</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">Place a “Code” node after your custom node to output timing.</p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">const start = $node["CustomNode"].getExecutionData().startTime;
const end = new Date();
console.log(`CustomNode duration: ${end - start} ms`);
</pre>
<h3 style="margin-bottom: 45px; line-height: 1.3;">4.3 Generate a Flame Graph</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">Run <code>node --prof <your‑worker>.js</code> and feed the output to <code>node --prof-process</code>. The resulting flame graph pinpoints hot functions inside your worker script.</p>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">5. Cache Reusable Results – When and How</h2>
<table style="border-collapse: collapse; width: auto; margin-bottom: 2em;">
<thead>
<tr>
<th style="padding: 13px; border: 1px solid #ddd;">Situation</th>
<th style="padding: 13px; border: 1px solid #ddd;">Cache type</th>
<th style="padding: 13px; border: 1px solid #ddd;">Implementation</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;">Repeated API calls with identical parameters</td>
<td style="padding: 13px; border: 1px solid #ddd;">In‑memory LRU</td>
<td style="padding: 13px; border: 1px solid #ddd;"><code>npm i lru-cache</code> and store results for 5 min.</td>
</tr>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;">Large static lookup tables (e.g., country codes)</td>
<td style="padding: 13px; border: 1px solid #ddd;">Read‑only Map loaded at startup</td>
<td style="padding: 13px; border: 1px solid #ddd;">Load once in <code>module.exports = new Map([...])</code>.</td>
</tr>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;">Expensive transformations that rarely change</td>
<td style="padding: 13px; border: 1px solid #ddd;">External Redis</td>
<td style="padding: 13px; border: 1px solid #ddd;">Use <code>ioredis</code> and set TTL = 1 h.</td>
</tr>
</tbody>
</table>
<h3 style="margin-bottom: 45px; line-height: 1.3;">5.1 LRU Cache Setup (4 lines)</h3>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">import LRU from 'lru-cache';
const cache = new LRU<string, any>({ max: 500, ttl: 300_000 }); // 5 min TTL
</pre>
<h3 style="margin-bottom: 45px; line-height: 1.3;">5.2 Cached fetch helper (5 lines)</h3>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">async function cachedFetch(key: string, fetcher: () => Promise) {
if (cache.has(key)) return cache.get(key);
const result = await fetcher();
cache.set(key, result);
return result;
}
</pre>
<h3 style="margin-bottom: 45px; line-height: 1.3;">5.3 Use inside <code>execute()</code> (4 lines)</h3>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">const data = await cachedFetch(item.id, () => heavySyncFunction(item));
results.push({ json: data });
</pre>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>EEFA note:</strong> In Docker‑orchestrated environments each container has its own memory space; a per‑container LRU cache does not share state across replicas. For horizontal scaling, prefer an external Redis cache.</p>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">6. Test Performance Before Deployment</h2>
<table style="border-collapse: collapse; width: auto; margin-bottom: 2em;">
<thead>
<tr>
<th style="padding: 13px; border: 1px solid #ddd;">Test</th>
<th style="padding: 13px; border: 1px solid #ddd;">Tool</th>
<th style="padding: 13px; border: 1px solid #ddd;">What it measures</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;">Unit benchmark</td>
<td style="padding: 13px; border: 1px solid #ddd;"><code>benchmark.js</code> (npm)</td>
<td style="padding: 13px; border: 1px solid #ddd;">Function‑level latency, GC pauses</td>
</tr>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;">End‑to‑end workflow run</td>
<td style="padding: 13px; border: 1px solid #ddd;"><code>n8n exec --run</code></td>
<td style="padding: 13px; border: 1px solid #ddd;">Full‑pipeline duration, memory peak</td>
</tr>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;">Load simulation</td>
<td style="padding: 13px; border: 1px solid #ddd;"><code>k6</code> script calling the REST API</td>
<td style="padding: 13px; border: 1px solid #ddd;">Concurrency impact, throttling behavior</td>
</tr>
</tbody>
</table>
<h3 style="margin-bottom: 45px; line-height: 1.3;">6.1 Benchmark script – Suite definition (4 lines)</h3>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">const { Suite } = require('benchmark');
const { runInWorker } = require('./customNode');
</pre>
<h3 style="margin-bottom: 45px; line-height: 1.3;">6.2 Benchmark test & execution (5 lines)</h3>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">new Suite()
.add('process 100 items', async () => {
const items = Array.from({ length: 100 }, (_, i) => ({ id: i }));
await Promise.all(items.map(i => runInWorker(i)));
})
.on('cycle', e => console.log(String(e.target)))
.run({ async: true });
</pre>
<p style="margin-bottom: 2em; line-height: 1.9;">Run <code>node bench.js</code> and aim for <strong>≤ 5 ms per item</strong> on a 2 CPU container.</p>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">7. Production‑Ready Deployment Checklist</h2>
<ul style="margin-bottom: 1.7em; line-height: 1.9; list-style-type: disc; padding-left: 1.5em;">
<li><strong>Limit concurrency</strong> in the node (use env var <code>CUSTOM_NODE_MAX_CONCURRENCY</code>).</li>
<li><strong>Set V8 memory flag</strong> in Dockerfile if needed: <code>ENV NODE_OPTIONS="--max-old-space-size=1024"</code> (adjust to container limit).</li>
<li><strong>Expose health endpoint</strong> that runs a lightweight version of the node to verify it starts without OOM.</li>
<li><strong>Log duration</strong> and <strong>error rates</strong> to a centralized observability platform (e.g., Grafana Loki).</li>
<li><strong>Run the Docker‑performance‑tuning child page</strong> for container‑level tweaks: <a href="/docker-performance-tuning">Docker performance tuning for n8n</a>.</li>
</ul>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">8. Common Errors & Quick Fixes</h2>
<table style="border-collapse: collapse; width: auto; margin-bottom: 2em;">
<thead>
<tr>
<th style="padding: 13px; border: 1px solid #ddd;">Symptom</th>
<th style="padding: 13px; border: 1px solid #ddd;">Root cause</th>
<th style="padding: 13px; border: 1px solid #ddd;">One‑line fix</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;">RangeError: Maximum call stack size exceeded</td>
<td style="padding: 13px; border: 1px solid #ddd;">Recursive async calls without <code>await</code></td>
<td style="padding: 13px; border: 1px solid #ddd;">Add <code>await</code> before each <code>runInWorker</code> or use <code>Promise.allSettled</code>.</td>
</tr>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;">Error: Worker stopped with code 1</td>
<td style="padding: 13px; border: 1px solid #ddd;">Worker script throws uncaught error</td>
<td style="padding: 13px; border: 1px solid #ddd;">Wrap worker code in <code>try/catch</code> and <code>parentPort.postMessage({ error })</code>.</td>
</tr>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;">High memory usage after long runs</td>
<td style="padding: 13px; border: 1px solid #ddd;">Cache never expires</td>
<td style="padding: 13px; border: 1px solid #ddd;">Set appropriate TTL (<code>cache.ttl = 300_000</code>).</td>
</tr>
<tr>
<td style="padding: 13px; border: 1px solid #ddd;">Workflow stalls at 100 % CPU</td>
<td style="padding: 13px; border: 1px solid #ddd;">Concurrency > CPU cores</td>
<td style="padding: 13px; border: 1px solid #ddd;">Set <code>CUSTOM_NODE_MAX_CONCURRENCY = os.cpus().length</code>.</td>
</tr>
</tbody>
</table>
<h2 style="margin-bottom: 45px; line-height: 1.3;"></h2>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">Conclusion</h2>
<p style="margin-bottom: 2em; line-height: 1.9;">By converting blocking loops to capped‑concurrency worker threads, trimming payloads, caching repeatable results, and profiling with n8n’s built‑in tools, your custom node will stay responsive even under heavy load. Apply the checklist before shipping, monitor memory and CPU, and you’ll have a production‑ready node that scales reliably across containers and clusters.</p>
Step by Step Guide to solve custom node performance
Who this is for: n8n developers building custom nodes that must handle thousands of items in production‑grade workflows. We cover this in detail in the n8n Performance & Scaling Guide.
Quick Diagnosis
Your custom n8n node is slowing down the entire workflow because it processes data synchronously, allocates large objects in memory, or makes blocking I/O calls. The fastest fix for a featured‑snippet‑ready answer is: refactor the node to use asynchronous streams, limit in‑memory payload size, and benchmark with the built‑in profiler.
Returning a massive array without pagination or streaming.
Use this.helpers.returnJsonArray() only for ≤ 5 k items; otherwise stream.
Key takeaway: n8n expects nodes to be non‑blocking and memory‑conservative. Any deviation creates a bottleneck that propagates downstream.
2. Refactor Synchronous Loops into Asynchronous Streams
2.1 Bad Example – Blocking Loop
The node pulls all items, processes each synchronously, and returns a huge array.
// Get items and initialise results
const items = this.getInputData();
const results = [];
// Process each item synchronously
for (const item of items) {
const processed = heavySyncFunction(item.json);
results.push({ json: processed });
}
// Return the full result set
return this.prepareOutputData(results);
2.2 Good Example – Parallel Async Processing
Step 1 – Worker helper (4 lines). Runs heavy work in a separate thread and resolves a promise.
import { Worker } from 'worker_threads';
function runInWorker(data: any): Promise {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', { workerData: data });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', code => {
if (code !== 0) reject(new Error(`Worker stopped with code ${code}`));
});
});
}
Step 2 – Execute with limited concurrency (5 lines).
export async function execute(this: IExecuteFunctions) {
const items = this.getInputData();
const concurrency = Math.min(8, items.length);
const results: any[] = [];
for (let i = 0; i < items.length; i += concurrency) { const chunk = await Promise.all( items.slice(i, i + concurrency).map(item => runInWorker(item.json))
);
results.push(...chunk);
}
return this.prepareOutputData(results.map(r => ({ json: r })));
}
Why it works:
Each item runs in its own thread, freeing the main event loop.
Concurrency is capped (here at 8) to avoid CPU thrashing—critical on shared‑CPU containers.
EEFA warning: Worker threads increase memory usage per thread. Monitor the container’s memory (`docker stats` or `kubectl top pod`) and adjust concurrency accordingly. If you encounter any cpu profiling resolve them before continuing with the setup.
3. Reduce In‑Memory Payload Size
3.1 Trim Unnecessary Fields
Keep only the data you actually need before passing it downstream.
function prune(item: any) {
const { id, name, status } = item; // keep only what you need
return { id, name, status };
}
3.2 Use Chunked Processing
Batch size
Avg. latency (ms)
Memory (MiB)
500 items
120
45
2 000 items
460
180
5 000 items
1 200
420
Recommendation: Keep batch size ≤ 2 000 for typical 2 GiB containers. For larger volumes, split the workflow with a “SplitInBatches” node before your custom node. If you encounter any resource limiting with cgroups resolve them before continuing with the setup.
4. Leverage n8n’s Built‑In Profiling Tools
4.1 Enable Debug Logging
Add this to ~/.n8n/config to capture detailed execution timestamps.
execution:
logLevel: debug
4.2 Log Node Duration
Place a “Code” node after your custom node to output timing.
const start = $node["CustomNode"].getExecutionData().startTime;
const end = new Date();
console.log(`CustomNode duration: ${end - start} ms`);
4.3 Generate a Flame Graph
Run node --prof <your‑worker>.js and feed the output to node --prof-process. The resulting flame graph pinpoints hot functions inside your worker script.
5. Cache Reusable Results – When and How
Situation
Cache type
Implementation
Repeated API calls with identical parameters
In‑memory LRU
npm i lru-cache and store results for 5 min.
Large static lookup tables (e.g., country codes)
Read‑only Map loaded at startup
Load once in module.exports = new Map([...]).
Expensive transformations that rarely change
External Redis
Use ioredis and set TTL = 1 h.
5.1 LRU Cache Setup (4 lines)
import LRU from 'lru-cache';
const cache = new LRU<string, any>({ max: 500, ttl: 300_000 }); // 5 min TTL
5.2 Cached fetch helper (5 lines)
async function cachedFetch(key: string, fetcher: () => Promise) {
if (cache.has(key)) return cache.get(key);
const result = await fetcher();
cache.set(key, result);
return result;
}
5.3 Use inside execute() (4 lines)
const data = await cachedFetch(item.id, () => heavySyncFunction(item));
results.push({ json: data });
EEFA note: In Docker‑orchestrated environments each container has its own memory space; a per‑container LRU cache does not share state across replicas. For horizontal scaling, prefer an external Redis cache.
Add await before each runInWorker or use Promise.allSettled.
Error: Worker stopped with code 1
Worker script throws uncaught error
Wrap worker code in try/catch and parentPort.postMessage({ error }).
High memory usage after long runs
Cache never expires
Set appropriate TTL (cache.ttl = 300_000).
Workflow stalls at 100 % CPU
Concurrency > CPU cores
Set CUSTOM_NODE_MAX_CONCURRENCY = os.cpus().length.
Conclusion
By converting blocking loops to capped‑concurrency worker threads, trimming payloads, caching repeatable results, and profiling with n8n’s built‑in tools, your custom node will stay responsive even under heavy load. Apply the checklist before shipping, monitor memory and CPU, and you’ll have a production‑ready node that scales reliably across containers and clusters.