Skip to content

Performance Issue: interopDefault Proxy adds 60x overhead on property access #420

@mcollina

Description

@mcollina

Performance Issue: interopDefault Proxy adds 60x overhead on property access

Summary

The interopDefault Proxy wrapper applied to every loaded module introduces massive performance overhead (60-70x slower) for property access and function calls. This significantly impacts hot paths where exported functions are called frequently.

Root Cause

Every module loaded by jiti is wrapped in a Proxy (src/utils.ts:121-154). This proxy intercepts all property accesses:

return new Proxy(mod, {
  get(target, prop, receiver) {
    if (prop === "__esModule") {
      return true;
    }
    if (prop === "default") {
      // ... handle default
    }
    if (Reflect.has(target, prop)) {
      return Reflect.get(target, prop, receiver);
    }
    if (defIsObj && !((def instanceof Promise))) {
      let fallback = Reflect.get(def, prop, receiver);
      if (typeof fallback === "function") {
        fallback = fallback.bind(def);  // .bind() on EVERY access!
      }
      return fallback;
    }
  },
  // ...
});

The problems:

  1. Every property access goes through the Proxy get trap
  2. Reflect.has() is called on every access before Reflect.get()
  3. For fallback to default export, .bind() is called on every single access, not cached

Benchmark Results

Benchmarking with 1,000,000 iterations...

--- Named export (add) ---
jiti (interopDefault=true):  96.04ms
jiti (interopDefault=false): 1.32ms
native import:               1.46ms

Overhead (interopDefault): 65.90x slower
Overhead (no interop):     0.90x slower

--- Fallback to default (calling multiply directly) ---
jiti (fallback + bind):      55.88ms
native import:               1.47ms

Overhead: 38.08x slower

--- Property access overhead (no function call) ---
jiti property access:        99.03ms
native property access:      1.58ms

Overhead: 62.72x slower

Benchmark Code

/**
 * Run: node test/bench-interop.mjs
 */

import { createJiti } from '../lib/jiti.mjs';
import { writeFileSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';

// Create a simple test module
const testModulePath = join(import.meta.dirname, '_bench-module.mjs');
writeFileSync(testModulePath, `
export function add(a, b) {
  return a + b;
}

export default {
  multiply(a, b) {
    return a * b;
  }
};
`);

const ITERATIONS = 1_000_000;

console.log(`\nBenchmarking with ${ITERATIONS.toLocaleString()} iterations...\n`);

// Load via jiti (with interopDefault proxy)
const jiti = createJiti(import.meta.url);
const jitiMod = await jiti.import(testModulePath);

// Load natively (no proxy)
const nativeMod = await import(testModulePath);

// Also test with interopDefault disabled
const jitiNoInterop = createJiti(import.meta.url, { interopDefault: false });
const jitiModNoInterop = await jitiNoInterop.import(testModulePath);

console.log('--- Named export (add) ---');

// Warm up
for (let i = 0; i < 1000; i++) {
  jitiMod.add(1, 2);
  nativeMod.add(1, 2);
  jitiModNoInterop.add(1, 2);
}

// Benchmark jiti with interopDefault
let start = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
  jitiMod.add(1, 2);
}
const jitiTime = performance.now() - start;
console.log(`jiti (interopDefault=true):  ${jitiTime.toFixed(2)}ms`);

// Benchmark jiti without interopDefault
start = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
  jitiModNoInterop.add(1, 2);
}
const jitiNoInteropTime = performance.now() - start;
console.log(`jiti (interopDefault=false): ${jitiNoInteropTime.toFixed(2)}ms`);

// Benchmark native
start = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
  nativeMod.add(1, 2);
}
const nativeTime = performance.now() - start;
console.log(`native import:               ${nativeTime.toFixed(2)}ms`);

console.log(`\nOverhead (interopDefault): ${(jitiTime / nativeTime).toFixed(2)}x slower`);
console.log(`Overhead (no interop):     ${(jitiNoInteropTime / nativeTime).toFixed(2)}x slower`);

console.log('\n--- Fallback to default (calling multiply directly) ---');

// Warm up
for (let i = 0; i < 1000; i++) {
  jitiMod.multiply(2, 3);
  nativeMod.default.multiply(2, 3);
}

// Benchmark jiti - this goes through the fallback path with .bind() on every call!
start = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
  jitiMod.multiply(2, 3);
}
const jitiFallbackTime = performance.now() - start;
console.log(`jiti (fallback + bind):      ${jitiFallbackTime.toFixed(2)}ms`);

// Benchmark native
start = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
  nativeMod.default.multiply(2, 3);
}
const nativeDefaultTime = performance.now() - start;
console.log(`native import:               ${nativeDefaultTime.toFixed(2)}ms`);

console.log(`\nOverhead: ${(jitiFallbackTime / nativeDefaultTime).toFixed(2)}x slower`);

console.log('\n--- Property access overhead (no function call) ---');

// Create module with just a property
const propModulePath = join(import.meta.dirname, '_bench-prop-module.mjs');
writeFileSync(propModulePath, `export const value = 42;`);

const jitiPropMod = await jiti.import(propModulePath);
const nativePropMod = await import(propModulePath);

// Warm up
let sum = 0;
for (let i = 0; i < 1000; i++) {
  sum += jitiPropMod.value;
  sum += nativePropMod.value;
}

start = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
  sum += jitiPropMod.value;
}
const jitiPropTime = performance.now() - start;
console.log(`jiti property access:        ${jitiPropTime.toFixed(2)}ms`);

start = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
  sum += nativePropMod.value;
}
const nativePropTime = performance.now() - start;
console.log(`native property access:      ${nativePropTime.toFixed(2)}ms`);

console.log(`\nOverhead: ${(jitiPropTime / nativePropTime).toFixed(2)}x slower`);

// Cleanup
unlinkSync(testModulePath);
unlinkSync(propModulePath);

Impact

Any application using jiti that calls exported functions in a hot path will see significant performance degradation. This affects:

  • Config loading libraries that re-read config values frequently
  • Any module with frequently called utility functions
  • Libraries that use jiti for TypeScript support at runtime

Workaround

Users can disable interopDefault to get native performance:

const jiti = createJiti(import.meta.url, { interopDefault: false });

However, this may break CJS/ESM interop for some modules.

Potential Solutions

  1. Cache bound functions: Store the result of .bind() instead of calling it on every access
  2. Copy properties at wrap time: Instead of using a Proxy, copy all properties from both the module and default export onto a new object at wrap time
  3. Lazy Proxy creation: Only wrap modules that actually need interop (have both named exports and a default export object)
  4. Use Object.defineProperty with getters: Define getters once that return the correct values, avoiding per-access overhead

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions