Isolate pooling added to expression engine

V8 isolates are now pooled for workflow executions, eliminating the cold-start latency that came with creating a new isolate for each expression.
When workflows evaluate expressions using the VM engine, n8n previously created and destroyed V8 isolates for each evaluation. This cold-start overhead added latency to every expression.
A new isolate pool keeps a set of pre-warmed V8 isolates ready. When a workflow execution begins, it acquires one isolate from the pool and holds it for the duration. On completion, the isolate returns to the pool for background replenishment. The pool size scales with the execution concurrency limit, ensuring a warm isolate is always available when a new execution starts.
Credential resolution outside the execution context uses the same acquire-evaluate-release path. If the pool is exhausted, it falls back to a cold-start bridge rather than failing.
In the @n8n/expression-runtime package, the ExpressionEvaluator now manages bridges through a IsolatePool instance, tracking them by Expression owner using WeakMap. The acquire(caller) and release(caller) methods bind an isolate to an Expression instance for its lifetime. New configuration options control pool behavior: N8N_EXPRESSION_ENGINE selects legacy or VM mode, N8N_EXPRESSION_ENGINE_POOL_SIZE sets the pool size, and N8N_EXPRESSION_ENGINE_MAX_CODE_CACHE_SIZE bounds the AST cache.
The changes span workflow execution, live/test/waiting webhooks, and credential resolution paths, all now following the acquire-at-start, release-on-finish pattern. This is part of a broader initiative to bring VM-based expression isolation to n8n.
View Original GitHub Description
Summary
This PR adds isolate pooling to the VM expression engine, following an isolate-per-workflow approach: each workflow execution acquires one isolate at start, holds it for the execution's lifetime, and disposes it on completion. The pool pre-warms replacements in the background to eliminate cold-start latency.
- Pool size matches the execution concurrency limit, so a warm bridge is always available when an execution starts.
- Credential resolution (outside execution context) uses the same acquire/evaluate/release path; if the pool is exhausted it falls back to a cold-start bridge.
- The
Expressioninstance is the owner key —acquireIsolate()/releaseIsolate()are instance methods that usethisas the WeakMap key, replacing the previousexecutionId-based Map.
Flow
sequenceDiagram
participant WE as WorkflowExecute
participant Expr as Expression (instance)
participant Eval as ExpressionEvaluator
participant Pool as IsolatePool
participant Bridge as IsolatedVmBridge
participant Isolate as V8 Isolate
Note over WE,Isolate: Execution start
WE->>Expr: acquireIsolate()
Expr->>Eval: acquire(this)
Eval->>Pool: acquire()
Note over Pool: Pool size = concurrency limit,<br/>bridge always available
Pool-->>Eval: bridge
Pool->>Pool: replenish() in background
Eval->>Eval: bridgesByOwner.set(expr, bridge)
Note over WE,Isolate: Expression evaluation (sync, repeats per expression)
WE->>Expr: renderExpression(expression, data)
Expr->>Eval: evaluate(expression, data, this, { timezone })
Eval->>Eval: getBridge(this) → bridgesByOwner.get(expr)
Eval->>Eval: getTransformedCode(expression) → code cache or Tournament
Eval->>Bridge: execute(transformedCode, data, { timezone })
Bridge->>Isolate: script.runSync(wrappedCode)
Isolate-->>Bridge: result
Bridge-->>Eval: result
Eval-->>Expr: result
Expr-->>WE: result
Note over WE,Isolate: Execution end (in .finally())
WE->>Expr: releaseIsolate()
Expr->>Eval: release(this)
Eval->>Eval: bridgesByOwner.delete(expr)
Eval->>Pool: release(bridge)
Pool->>Bridge: dispose()
Bridge->>Isolate: isolate.dispose()
Note over Isolate: V8 heap freed
Related Linear tickets, Github issues, and Community forum posts
https://linear.app/n8n/issue/CAT-2279
Review / Merge checklist
- PR title and summary are descriptive. (conventions)
- Docs updated or follow-up ticket created.
- Tests included.
- PR Labeled with
Backport to Beta,Backport to Stable, orBackport to v1(if the PR is an urgent fix that needs to be backported)