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:
- Every property access goes through the Proxy
get trap
Reflect.has() is called on every access before Reflect.get()
- 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
- Cache bound functions: Store the result of
.bind() instead of calling it on every access
- 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
- Lazy Proxy creation: Only wrap modules that actually need interop (have both named exports and a default export object)
- Use
Object.defineProperty with getters: Define getters once that return the correct values, avoiding per-access overhead
Performance Issue: interopDefault Proxy adds 60x overhead on property access
Summary
The
interopDefaultProxy 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:The problems:
gettrapReflect.has()is called on every access beforeReflect.get().bind()is called on every single access, not cachedBenchmark Results
Benchmark Code
Impact
Any application using jiti that calls exported functions in a hot path will see significant performance degradation. This affects:
Workaround
Users can disable interopDefault to get native performance:
However, this may break CJS/ESM interop for some modules.
Potential Solutions
.bind()instead of calling it on every accessObject.definePropertywith getters: Define getters once that return the correct values, avoiding per-access overhead