DEV Community

Cover image for N8N Code Node Best Practices for Javascript / Typescript (+Task Runner Examples)
Yigit Konur
Yigit Konur

Posted on

N8N Code Node Best Practices for Javascript / Typescript (+Task Runner Examples)

1. Introduction and Core Fundamentals

n8n is a robust, open-source workflow automation tool and low-code platform built on Node.js, making JavaScript its native language. While standard nodes (such as Set, If, Merge, or Switch) allow for visual, linear task execution, the Code Node and Inline Expressions represent the "engine room" of sophisticated automation. These tools empower users to implement custom logic, complex data manipulations, and performance optimizations that standard nodes cannot achieve.

1.1 The Role of JavaScript and TypeScript

The Code Node is the unrestricted layer of the platform. Unlike Python (which runs via an adapter in standard setups), JavaScript in n8n runs natively, offering the highest possible performance for data manipulation. A single Code node can often replace a "spaghetti" chain of 10 to 15 standard nodes (like Set or If), resulting in cleaner, more maintainable, and significantly more performant workflows.

Why Use JS/TS in n8n?

  • Native Speed: No serialization overhead between the host and the interpreter (in Standard mode).
  • Complex Logic: Handle nested loops, complex conditionals, regex operations, and advanced control flow.
  • Data Shaping: map, reduce, filter, and flatMap are exponentially faster than visual loops for large arrays.
  • Async/Await: First-class support for asynchronous operations and Promises.
  • TypeScript: With External Task Runners, you can enforce Type Safety, preventing runtime errors in critical financial or data pipelines.
  • Batch Processing: Necessary when processing multiple items together or performing cross-item logic.

1.2 Performance Benefits and Benchmarks

The performance benefits of consolidating logic into a Code node are substantial. Processing arrays with thousands of items via multiple node transitions incurs significant overhead due to the instantiation cost of multiple node contexts.

Benchmarks (Processing a dataset of 1,000 items):

  • Code Node: ~580ms to ~582ms (Compiled V8 execution; reduced node transition overhead).
  • Standard Set/If Chains: ~1,800ms to ~1,820ms+ (Node instantiation overhead).

Verdict: For batch processing (>100 items) or high-volume data, the Code Node is approximately 3x faster and is mandatory for performance and scalability.


2. Architecture: Standard vs. External Task Runner

To use TypeScript or Custom NPM packages, you must understand where your code executes. You must select one of two architectural options.

2.1 Option A: Standard Execution (Default)

  • Engine: The main n8n Node.js process (using vm2 sandbox).
  • Best For: Standard transformations, speed, lightweight logic.
  • Startup: Instant (0ms latency).
  • Limitations:
    • No TypeScript. Pure JS only.
    • Shared Resources: Heavy CPU scripts block the main n8n thread (can freeze the UI).
    • Restricted Libs: Cannot easily install custom NPM packages (requires restarting main container). Standard require is restricted. No direct file system access (fs) or shell commands (os.system) unless explicitly allowed.

2.2 Option B: External Task Runner (The Enterprise Choice)

  • Engine: A separate, isolated Docker container (The "Runner").
  • Best For: TypeScript, Heavy CPU tasks, Custom NPM libraries (axios, lodash, zod).
  • Capabilities: Full isolation. If a script crashes, n8n stays alive.
  • TypeScript: Can run TS natively via tsx or Node's --experimental-strip-types (Node 22+).
  • Startup: Slight network latency (~10ms) over WebSocket, but unlimited scalability.

2.3 Configuration: Enabling TypeScript & Custom Packages

To run TypeScript or use libraries like lodash or zod, you generally need Option B (External Runner).

Step 1: Build a Custom Runner Image

The default runner image is generic. You must extend it to add TypeScript support and your desired libraries.

Dockerfile (for External Runner):

# Use the official n8n runner image (match your n8n version)
FROM n8nio/runners:latest

USER root

# 1. Install TypeScript and the 'tsx' loader (for fast TS execution)
# 2. Install custom libraries (e.g., lodash, date-fns, zod)
RUN pnpm add typescript tsx lodash date-fns zod axios

# Copy your configuration file
COPY n8n-task-runners.json /etc/n8n-task-runners.json

USER runner
Enter fullscreen mode Exit fullscreen mode

Step 2: Configure the Allow List

You must strictly allow these modules in your n8n-task-runners.json file.

n8n-task-runners.json:

{
  "task-runners": {
    "js": {
      "env-overrides": {
        # Allow these packages to be imported in the Code Node
        "NODE_FUNCTION_ALLOW_EXTERNAL": "lodash,date-fns,zod,axios,typescript",
        # Enable Node's native type stripping (Node 22+) or generic settings
        "NODE_OPTIONS": "--experimental-strip-types" 
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Supported Languages and Syntax

3.1 JavaScript (ES6+)

This is the native language of n8n and supports modern ECMAScript features:

  • Arrow functions, Destructuring assignment, Template literals.
  • Async/await patterns, Spread operators.
  • Optional chaining (?.) and nullish coalescing (??).

3.2 Python

Users can utilize Python for scripting, with specific environment nuances:

  • Pyodide: WASM-based Python running inside Node.js. Blocks os.system and importlib for security.
  • Native Python: Requires Task Runner setup.
  • Variable Renaming: Python syntax forbids variables starting with $. n8n automatically renames them:
    • $input_input
    • $json_json
    • $node_node
    • $vars_vars

3.3 The Sandbox Environment (vm2)

n8n utilizes vm2 (NodeVM) to isolate code execution.

  • Restrictions: Standard Node.js libraries (like fs or os) are not accessible by default.
  • Console Output: console.log statements are redirected to the UI execution panel or server logs depending on deployment.
  • External Modules: Access requires self-hosted instances with the NODE_FUNCTION_ALLOW_EXTERNAL environment variable set (e.g., NODE_FUNCTION_ALLOW_EXTERNAL=lodash,axios).

4. Inline Expressions vs. Code Node

Understanding the distinction between these two layers is critical.

4.1 Comparative Overview

Aspect Inline Expressions Code Node
Where Directly in node parameters (toggle “Fixed / Expression”). A separate, dedicated node.
Syntax Single-line JS wrapped in {{ }}. Multi-line JS (ES6+) or Python.
Line Limits Must remain on a single line. No limits; supports functions, classes, async.
Ideal Use Case Quick transforms (.isEmail()), field mapping. Complex logic, loops, batch processing.
Data Handling Returns a value immediately; no intermediate steps. Multi-step processing with variables.
Loops/Async ❌ No loops, no async. for, .map, await supported.
Type Safety ❌ No. ✅ Interfaces / Enums (with TS Runner).

4.2 The Tournament Templating Engine

Since n8n v1.9.0, inline expressions use the "Tournament" engine, providing "syntactic sugar" for data transformations.

Critical Warning: These built-in transformation functions (e.g., .isEmail()) are not standard JavaScript methods. They work inside {{ ... }} but will fail if used directly on a variable inside a Code Node script. To use them within a Code Node, you must wrap them in the $evaluateExpression() helper:

const isEmail = $evaluateExpression('{{ $json.email.isEmail() }}', itemIndex);
Enter fullscreen mode Exit fullscreen mode

5. Inline Expressions: Built-in Transformations Reference

These methods are available within {{ }} syntax.

5.1 String Transformations

  • .isEmail(): Checks for valid email format. {{ "john@example.com".isEmail() }}true
  • .extractDomain(): Extracts domain from URL. {{ "https://google.com/path".extractDomain() }}"google.com"
  • .removeTags(): Removes HTML tags.
  • .base64Encode() / .base64Decode(): Base64 operations.
  • .toSnakeCase(): {{ "Hello World".toSnakeCase() }}"hello_world"
  • .toCamelCase(): {{ "hello_world".toCamelCase() }}"helloWorld"
  • .extractUrlPath(): Extracts path from URL.
  • .trim(), .toUpperCase(), .toLowerCase(): Standard string methods.

5.2 Array Transformations

  • .sum(): Sums numerical elements. {{ [10, 20].sum() }}30
  • .removeDuplicates(): Returns unique array.
  • .merge(array): Merges another array.
  • .isEmpty(): Boolean check for empty array.
  • .randomItem(), .first(), .last(): Element retrieval.

5.3 Number Transformations

  • .round(decimalPlaces): Rounds number. {{ $json.amount.round(2) }}
  • .toBoolean(): 0false, others → true.
  • .format(locale): Formats per locale (e.g., 'en-US').
  • .isEven(), .isOdd(): Boolean checks.

5.4 Object Transformations

  • .isEmpty(): Checks if object has no keys.
  • .removeField(key): Removes specific key.
  • .merge(object): Merges another object.
  • .toJsonString(): Converts to JSON string.

5.5 Date & Time Transformations (Luxon)

  • Global Shortcuts: {{ $now }} (Current DateTime), {{ $today }} (Start of current day).
  • .toDateTime(): Parses string to Luxon object.
  • .plus(amount, unit) / .minus(amount, unit): Time arithmetic.
  • .format(formatString): {{ $now.format('yyyy-MM-dd') }}.
  • .isWeekend(): Boolean check.

5.6 Inline-Only Helpers

  • $if(condition, trueVal, falseVal): Ternary helper. {{ $if($json.age > 18, "Adult", "Minor") }}
  • $ifEmpty(value, default): Returns default if value is null/undefined.
  • $max(a, b...), $min(a, b...): Returns max/min values.

6. Code Node: Data Access and Variables

6.1 Data Access Cheatsheet

Feature JS / TS Syntax (Best Practice) Python Equivalent Description
Input (All) $input.all() _input.all() Returns Array of all items (Run Once for All).
Legacy Input items N/A Legacy shorthand for $input.all().
Input (One) $input.item _input.item Returns current item (Run Once for Each).
Input (First) $input.first() _input.first() First item in batch.
Input (Last) $input.last() _input.last() Last item in batch.
Input (Index) $input.item(index) _input.item(index) Item at specific index.
Node Ref $('NodeName').first().json N/A Access output of previous nodes.
Node All $('NodeName').all() N/A All items from specific node.
Env Vars $env.API_KEY _env.API_KEY System environment variables.
Workflow Vars $vars.myConfig _vars.myConfig Global workflow variables.
JSON Access item.json?.myField N/A Safe access (prevents "undefined" error).
Context $input.context.noItemsLeft _input.context.noItemsLeft Detects end of batch.

6.2 JS Equivalents to Inline Transformations

Since Tournament helpers don't work natively in Code Nodes, use these JS equivalents:

  • Check Email: const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(item.json.email);
  • Extract Domain: const domain = new URL(item.json.url).hostname;
  • Clean Text: const clean = text.replace(/<[^>]*>?/gm, '');
  • Sum: const sum = [1,2].reduce((a,b) => a+b, 0);
  • Remove Duplicates: const unique = [...new Set(item.json.ids)];
  • Date Format:

    const DateTime = require('luxon').DateTime;
    DateTime.now().toFormat('yyyy-MM-dd');
    

7. Execution Modes & Return Contracts

7.1 Mode: "Run Once for All Items" (Recommended)

  • Behavior: The code executes exactly once for the entire batch.
  • Input: $input.all() (Array).
  • Performance: High. The Sandbox/VM is instantiated only once.
  • Use Case: Aggregation, Filtering, Batching, Cross-Item Logic.

7.2 Mode: "Run Once for Each Item"

  • Behavior: The code executes separately (N times) for every incoming item.
  • Input: $input.item (Object).
  • Performance: Lower.
    • Standard Mode: High overhead due to Sandbox reset/re-instantiation.
    • External Runner: Incurs high network overhead (~10ms latency per item) + overhead of multiple context startups.
  • Note: Avoid for large datasets (>1,000 items) unless strict isolation is required.

7.3 The Output Contract

You must strictly return: INodeExecutionData[].

  1. Always Return an Array: Even for single items.
  2. Use the json Key: All data properties must be wrapped in json.
  3. No Primitives: Do not return strings/numbers directly.
  4. No Unknown Top-Level Keys: Only json, binary, and pairedItem are allowed.

Valid Output Example:

return [
  { 
    json: { id: 1, success: true },
    binary: { ... } // Optional
  }
];
Enter fullscreen mode Exit fullscreen mode

Item Linking (pairedItem):
To preserve UI debugging lineage and itemMatching logic when filtering, link output back to input index.

return items.map((item, index) => ({
  json: { ...item.json, processed: true },
  pairedItem: { item: index }
}));
Enter fullscreen mode Exit fullscreen mode

8. Core Programming Patterns

8.1 Defensive Mapping (Transformation)

Use Optional Chaining (?.) and Nullish Coalescing (??) to handle dirty API data safely.

return $input.all().map(item => {
  const d = item.json;
  return {
    json: {
      userId: d.id,
      // If address is missing, or city is missing, return 'Unknown'
      city: d.contact?.address?.city?.trim() ?? 'Unknown',
      // Convert tags array to string, or empty string if null
      tags: d.tags?.join(', ') || ''
    }
  };
});
Enter fullscreen mode Exit fullscreen mode

8.2 Filtering

Chain .filter() and .map(). Optimization: Filter early to stop processing invalid items immediately.

const items = $input.all();
return items
  .map(item => item.json)
  .filter(user => user.isActive && user.email.includes('@company.com'))
  .map(user => ({ json: { ...user, valid: true } }));
Enter fullscreen mode Exit fullscreen mode

8.3 Grouping (reduce)

Group orders by Customer ID or Email.

const items = $input.all();
const grouped = items.reduce((acc, item) => {
  const customer = item.json.customerName;
  if (!acc[customer]) {
    acc[customer] = { customer, totalOrders: 0, items: [] };
  }
  acc[customer].totalOrders++;
  acc[customer].items.push(item.json.product);
  return acc;
}, {});

// Convert Object back to Array for n8n
return Object.values(grouped).map(g => ({ json: g }));
Enter fullscreen mode Exit fullscreen mode

8.4 Flattening (flatMap)

Explode nested arrays (e.g., 1 Order → 5 Line Items).

const items = $input.all();
return items.flatMap(item => 
  item.json.lineItems.map(product => ({
    json: {
      orderId: item.json.id,
      productName: product.name,
      price: product.price
    }
  }))
);
Enter fullscreen mode Exit fullscreen mode

8.5 Recursive Flattening (Deep Objects)

For deeply nested responses (Stripe, Salesforce).

const item = $input.item.json;

function flattenObject(obj, prefix = '', maxDepth = 5, currentDepth = 0) {
  if (currentDepth > maxDepth) {
    console.warn('Max flattening depth reached');
    return {};
  }
  const flattened = {};
  for (const [key, value] of Object.entries(obj)) {
    const newKey = prefix ? `${prefix}_${key}` : key;
    if (value === null || value === undefined) {
      flattened[newKey] = null;
    } else if (typeof value === 'object' && !Array.isArray(value) && value.constructor === Object) {
      Object.assign(flattened, flattenObject(value, newKey, maxDepth, currentDepth + 1));
    } else if (Array.isArray(value)) {
      flattened[`${newKey}_count`] = value.length;
      flattened[`${newKey}_json`] = JSON.stringify(value);
    } else {
      flattened[newKey] = value;
    }
  }
  return flattened;
}

const flattened = flattenObject(item);
return { json: flattened };
Enter fullscreen mode Exit fullscreen mode

8.6 Merging Two Datasets (Lookup Map)

Faster than visual Merge node (O(n) vs O(n²)).

// 1. Get Reference Data
const users = $('Get Users').all();
// 2. Create Lookup Map (ID -> User Object)
const userMap = new Map(users.map(u => [u.json.id, u.json]));

// 3. Merge into current stream
return $input.all().map(item => {
  const userId = item.json.userId;
  return {
    json: {
      ...item.json,
      userData: userMap.get(userId) || { error: 'User not found' }
    }
  };
});
Enter fullscreen mode Exit fullscreen mode

9. Advanced Programming & TypeScript

9.1 TypeScript Implementation (External Runner)

Since the UI only shows "JavaScript", you write TS code in the JS box, and the External Runner handles it.

Pattern: JIT Transpilation & Interfaces

interface Customer {
  id: number;
  email: string;
  isActive?: boolean;
}

const items = $input.all();
return items.map(item => {
  const customer = item.json as Customer; // Type Assertion
  return {
    json: {
      id: customer.id,
      emailLabel: `Contact: ${customer.email.toUpperCase()}`,
      valid: customer.isActive ?? false
    }
  };
});
Enter fullscreen mode Exit fullscreen mode

Pattern: Validation with Zod
Requires zod in NODE_FUNCTION_ALLOW_EXTERNAL.

const { z } = require('zod');
const UserSchema = z.object({
  id: z.number(),
  email: z.string().email(),
  role: z.enum(['admin', 'user']).default('user')
});

return $input.all().map(item => {
  const result = UserSchema.safeParse(item.json);
  if (!result.success) {
    return { json: { ...item.json, error: result.error.issues } };
  }
  return { json: { ...result.data, verified: true } };
});
Enter fullscreen mode Exit fullscreen mode

9.2 HTTP Requests with Async/Await

Use this.helpers.httpRequest or httpRequestWithAuthentication.

Concurrent Requests (Batching):

const items = $input.all();
const batchSize = 10;
const results = [];

for (let i = 0; i < items.length; i += batchSize) {
  const batch = items.slice(i, i + batchSize);
  const promises = batch.map(async (item) => {
    try {
      const response = await this.helpers.httpRequest({
        method: 'POST',
        url: 'https://api.example.com/process',
        body: item.json,
        json: true
      });
      return { json: { originalId: item.json.id, success: true, data: response } };
    } catch (error) {
      return { json: { originalId: item.json.id, success: false, error: error.message } };
    }
  });

  const batchResults = await Promise.all(promises);
  results.push(...batchResults);
  // Optional delay
  if (i + batchSize < items.length) await new Promise(r => setTimeout(r, 1000));
}
return results;
Enter fullscreen mode Exit fullscreen mode

Retry Logic with Exponential Backoff:

async function makeRequestWithRetry(url, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await this.helpers.httpRequest({ method: 'GET', url, json: true });
      if (response.statusCode === 429) throw new Error('Rate Limited');
      return response;
    } catch (error) {
      if (attempt < maxRetries - 1) {
        const delayMs = Math.pow(2, attempt) * 500;
        await new Promise(resolve => setTimeout(resolve, delayMs));
        continue;
      }
      throw error;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

9.3 Caching & Static Data

Persist data between executions or implement in-memory caching.

Cache Pattern (Lead Enrichment):

const staticData = $getWorkflowStaticData('global');
if (!staticData.cache) staticData.cache = {};

const items = $input.all();
const results = [];

for (const item of items) {
  const key = item.json.id;
  if (staticData.cache[key]) {
    results.push({ json: staticData.cache[key] });
  } else {
    // Make API call and cache
    const result = await this.helpers.httpRequest({...});
    staticData.cache[key] = result;
    results.push({ json: result });
  }
}
return results;
Enter fullscreen mode Exit fullscreen mode

10. Binary Data Handling

10.1 Safe Binary to Text Conversion

return items.map(item => {
  try {
    const binaryData = item.binary.data; // Base64 string
    const textContent = Buffer.from(binaryData, 'base64').toString('utf-8');
    return { json: { ...item.json, content: textContent } };
  } catch (error) {
    return { json: { ...item.json, error: error.message } };
  }
});
Enter fullscreen mode Exit fullscreen mode

10.2 Creating Binary Files

const htmlContent = `<html><body><h1>Report</h1><p>Date: ${new Date().toISOString()}</p></body></html>`;
const buffer = Buffer.from(htmlContent, 'utf8');

const binaryData = await this.helpers.prepareBinaryData(buffer, 'report.html', 'text/html');

return [{
  json: { generated: true, timestamp: new Date().toISOString() },
  binary: { data: binaryData }
}];
Enter fullscreen mode Exit fullscreen mode

11. Performance Optimization & Limits

11.1 Memory Management (Heap)

  • Issue: JS Objects consume significant RAM (e.g., 100MB CSV -> 400MB Objects).
  • External Runner Fix: Set N8N_RUNNERS_MAX_OLD_SPACE_SIZE=4096 to allow 4GB RAM (default is often 512MB).

11.2 Split In Batches Pattern

For >5,000 items, add a Split In Batches node before the Code node. This prevents memory crashes by processing chunks (e.g., 50 items) at a time. The Code node runs once per batch.

11.3 Optimization Checklist

  1. Use Maps: Always use new Map() for lookups, never .find() inside a loop.
  2. Filter Early: Filter array before expensive transformations.
  3. Avoid Mutating: Return new objects { json: { ...item.json } } (immutability).
  4. Use Code Node: For batch processing >1000 items, Code Node is ~3x faster than Standard Set/If chains.

12. Real-World Recipes

12.1 LinkedIn URL ID Extraction (Inline Expression)

{{ $("LinkedIn").item.json.query.url.extractUrlPath().split(":")[3].replace('/', '') }}
Enter fullscreen mode Exit fullscreen mode

Logic: Extracts path, splits by colon, takes 4th element, removes slash.

12.2 Google Ads Automation

Analyzes search terms, calculates cost-per-conversion, filters underperformers, and formats for Slack.

const keywords = $input.all();
const analyzed = keywords.map(item => ({
  ...item.json,
  costPerConversion: item.json.cost / (item.json.conversions || 1)
}));

const underperforming = analyzed
  .filter(k => k.costPerConversion > 50)
  .sort((a, b) => b.costPerConversion - a.costPerConversion);

return [{
  json: {
    message: `⚠️ ${underperforming.length} keywords need attention`,
    keywords: underperforming.slice(0, 10).map(k => `${k.term}: $${k.costPerConversion.toFixed(2)}`)
  }
}];
Enter fullscreen mode Exit fullscreen mode

12.3 Generating Personalized Invoices

const items = $input.all();
const defaultShipping = $vars.defaultShipping || 'Standard';

return items.map((item, i) => {
  const data = item.json;
  const total = data.items.reduce((acc, curr) => acc + (curr.qty * curr.price), 0);
  const tax = total * 0.08; 
  const greeting = $evaluateExpression('{{ "Dear " + $json.firstName }}', i);

  return {
    json: {
      ...data,
      subtotal: total.toFixed(2),
      tax: tax.toFixed(2),
      total: (total + tax).toFixed(2),
      shipping: $ifEmpty(data.shipping, defaultShipping),
      greeting,
      generatedAt: new Date().toISOString()
    }
  };
});
Enter fullscreen mode Exit fullscreen mode

13. FAQ and Checklists

13.1 Common Questions

  1. Can I use require in the Code Node?
    • Standard Mode: Generally No.
    • Runner Mode: Yes, if the package is in the Docker image and the ALLOW_EXTERNAL list.
  2. Why does my TypeScript code fail?
    • Ensure you are using the External Runner. Standard n8n knows only pure JS.
  3. How do I console log?
    • console.log('My Var:', myVar);. Check "Browser Console" (Standard) or "Server Logs" (Runners).
  4. Difference between $json and $input.item?
    • $json is for Inline Expressions.
    • $input.item.json is for Code Nodes.
  5. Why do I get "Unknown top-level item key"?
    • You returned { myField: 1 }. You must return { json: { myField: 1 } }.
  6. Why is my loop not waiting for the API call?
    • You likely used .forEach with async. Switch to for...of or Promise.all.
  7. Can I use JMESPath?
    • Yes, via $jmespath(data, query). Example: $jmespath(item.json, 'users[?age > \18].name').

13.2 Final "Before You Run" Checklist

  • [ ] Mode: Is "Run Once for All Items" selected for arrays?
  • [ ] Return: Is data wrapped in [{ json: ... }]?
  • [ ] Imports: If using require('lodash'), is it allowed in env variables and Docker image?
  • [ ] Syntax: Using $input.all() instead of legacy items?
  • [ ] Safety: Using optional chaining ?. and $ifEmpty?
  • [ ] Error Handling: Wrapped async logic in try-catch?
  • [ ] Immutability: Used spread syntax (...item.json)?
  • [ ] Lineage: Included pairedItem if filtering/merging is needed later?

Top comments (0)