Skip to content

[Bug] Re-imports in ESM are 20-25 times slower than re-requires in CommonJS #52369

@vitaliylag

Description

@vitaliylag

Version

21.7.2

Platform

Microsoft Windows NT 10.0.22631.0 x64

Subsystem

Windows 11

What steps will reproduce the bug?

To benchmark the performance of imports and requires you can use this script:

  1. Create the new directory in any location and create the "genModules.mjs" file with this content:
import fsp from 'node:fs/promises';

const MODULE_COUNT = 1000,
      TEST_COUNT   = 5;

for (const fastMode of [false, true]) {
	const wrapperName = fastMode ? 'wrapper_fast' : 'wrapper_slow',
	      dirBase_esm = fastMode ? 'esm_fast' : 'esm_slow',
	      dirBase_cjs = fastMode ? 'cjs_fast' : 'cjs_slow',
	      ext_esm     = fastMode ? '.mjs'     : '.js',
	      ext_cjs     = fastMode ? '.cjs'     : '.js';
	
	//1. Generate code of wrappers (these files import main files few times)
	
	const [wrapperCode_esm, wrapperCode_cjs] = (() => {
		const esm = [],
		      cjs = [];
		
		for (let i = 1; i <= TEST_COUNT; i++) {
			esm.push('globalThis.startTimeMs = performance.now();');
			esm.push(`await import('./${dirBase_esm}/main${i}${ext_esm}');`);
			esm.push('');
			
			cjs.push('globalThis.startTimeMs = performance.now();');
			cjs.push(`require('./${dirBase_cjs}/main${i}${ext_cjs}');`);
			cjs.push('');
		}
		
		return [
			esm.join('\n'),
			cjs.join('\n'),
		];
	})();
	
	//2. Generate code of main files (these files contain many imports)
	
	const [mainCode_esm, mainCode_cjs] = (() => {
		const esm  = [],
		      cjs  = [],
		      vars = [];
		
		for (let i = 1; i <= MODULE_COUNT; i++) {
			esm.push(`import m${i} from './modules/module${i}${ext_esm}';`);
			cjs.push(`const m${i} = require('./modules/module${i}${ext_cjs}');`);
			vars.push(`m${i}`);
		}
		
		const lastLine = `\nconsole.log(((performance.now() - globalThis.startTimeMs) / ${MODULE_COUNT}).toFixed(3) + ' ms, sum = ' + (${vars.join('+')}));\n`;
		esm.push(lastLine);
		cjs.push(lastLine);
		
		return [
			esm.join('\n'),
			cjs.join('\n'),
		];
	})();
	
	//3. Create dirs
	
	for (const dir of [
		`${dirBase_esm}`,
		`${dirBase_esm}/modules`,
		`${dirBase_cjs}`,
		`${dirBase_cjs}/modules`,
	]) {
		try {
			await fsp.mkdir(dir);
		} catch {}
	}
	
	//4. Create files
	
	//Wrappers
	await fsp.writeFile(`${wrapperName}.mjs`, wrapperCode_esm);
	await fsp.writeFile(`${wrapperName}.cjs`, wrapperCode_cjs);
	
	//package.json
	await fsp.writeFile(`${dirBase_esm}/package.json`, `{"type": "module"}`);
	await fsp.writeFile(`${dirBase_cjs}/package.json`, `{"type": "commonjs"}`);
	
	//Few main files
	for (let i = 1; i <= TEST_COUNT; i++) {
		await fsp.writeFile(`${dirBase_esm}/main${i}${ext_esm}`, mainCode_esm);
		await fsp.writeFile(`${dirBase_cjs}/main${i}${ext_cjs}`, mainCode_cjs);
	}
	
	//Many small modules
	for (let i = 1; i <= MODULE_COUNT; i++) {
		await fsp.writeFile(`${dirBase_esm}/modules/module${i}${ext_esm}`, `export default ${i};\n`);
		await fsp.writeFile(`${dirBase_cjs}/modules/module${i}${ext_cjs}`, `module.exports = ${i};\n`);
	}
}
  1. node genModules.mjs - this command will create many small modules and few wrappers so you can benchmark the performance of ESM imports and CJS requires.

  2. Use these commands to benchmark the performance of imports and requires:

    • node wrapper_slow.mjs - .js extension, imports (esm)
    • node wrapper_slow.cjs - .js extension, requires (cjs)
    • node wrapper_fast.mjs - .mjs extension, imports (esm)
    • node wrapper_fast.cjs - .cjs extension, requires (cjs)

Explanation of results:

  • 0.323 ms - average time of one unique include
  • 0.112 ms - average time of including the same module again
  • 0.122 ms - average time of including the same module again #⁠2
  • 0.107 ms - average time of including the same module again #⁠3
  • 0.093 ms - average time of including the same module again #⁠4

How often does it reproduce?

Always, even on different system.

What is the expected behavior?

Re-importing the same ES module should be as fast as re-requiring the same CJS module.

What do you see instead?

My results:

  • .js extension:

    • one unique import: 0.310 ms
    • one unique require: 0.300 ms (almost the same)
    • one re-import of the same ES module: 0.100 ms
    • one re-require of the same CJS module: 0.004 ms (~20-25 times faster than re-import)
  • .mjs/.cjs extension:

    • one unique import: 0.280 ms
    • one unique require: 0.270 ms (almost the same)
    • one re-import of the same ES module: 0.070 ms
    • one re-require of the same CJS module: 0.004 ms (~15-20 times faster than re-import)

As you can see re-importing the same ES module works ~15-25 times slower than re-require the same CJS module.

It really slows the Node.js down. For example, even simple script can include 50 modules, every module ~10 times. So we have 50 * 0.3 ms = 15 ms for unique includes and 50 * 9 * 0.1 ms = 45 ms for re-includes. The total time of includes is 60 ms. But it's important to understand that there may be thousands of modules in some cases.

Metadata

Metadata

Assignees

No one assigned

    Labels

    loadersIssues and PRs related to ES module loadersperformanceIssues and PRs related to the performance of Node.js.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions