Skip to content

module: synchronously load most ES modules - #62530

Open
GeoffreyBooth wants to merge 8 commits into
nodejs:mainfrom
GeoffreyBooth:synchronously-load-most-es-modules
Open

GeoffreyBooth wants to merge 8 commits into
nodejs:mainfrom
GeoffreyBooth:synchronously-load-most-es-modules

Conversation

@GeoffreyBooth

@GeoffreyBooth GeoffreyBooth commented Apr 1, 2026

Copy link
Copy Markdown
Member

Building on #55782, this PR uses the path @joyeecheung created for require(esm) to synchronously resolve and load all ES modules that lack top-level await, which is the vast majority of modules. The sync path is used when no async loader hooks, --import flags, or --inspect-brk are active; it falls back to the existing async path otherwise. Top-level await presence can only be determined after the module graph is instantiated, so if TLA is detected the already-instantiated graph falls back to async evaluation. In all cases the behavior is identical to the existing async path.

On current main, an ES module graph generates 14 + 5N promises for N modules; so 19 promises for a single module graph (one entry point that doesn’t import anything), 24 promises if that entry point imports one file, 29 promises for a three-module graph and so on.

In this PR, only one promise is created regardless of graph size: the low-level V8 module.evaluate() call that happens within module.evaluateSync(), where an immediately-resolved promise is created even for modules that don’t have top-level await. But still, it’s only one promise for an entire application, no matter how big the app is.

This PR adds a benchmark that focuses on the module loading flow that this PR improves:

                                              confidence improvement accuracy (*)   (**)  (***)
esm/startup-esm-graph.js n=100 modules='0250'                 0.71 %       ±3.39% ±4.47% ±5.74%
esm/startup-esm-graph.js n=100 modules='0500'                 0.45 %       ±3.15% ±4.15% ±5.33%
esm/startup-esm-graph.js n=100 modules='1000'                 1.96 %       ±3.19% ±4.21% ±5.40%
esm/startup-esm-graph.js n=100 modules='2000'                 1.08 %       ±3.12% ±4.11% ±5.28%

Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 4 comparisons, you can thus
expect the following amount of false-positive results:
  0.20 false positives, when considering a   5% risk acceptance (*, **, ***),
  0.04 false positives, when considering a   1% risk acceptance (**, ***),
  0.00 false positives, when considering a 0.1% risk acceptance (***)

So basically it’s within the margin of error.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/loaders
  • @nodejs/performance

@nodejs-github-bot nodejs-github-bot added esm Issues and PRs related to the ECMAScript Modules implementation. module Issues and PRs related to the module subsystem. needs-ci PRs that need a full CI run. labels Apr 1, 2026
@GeoffreyBooth
GeoffreyBooth force-pushed the synchronously-load-most-es-modules branch from 2bb88f9 to 9a7728c Compare April 1, 2026 02:17
@codecov

codecov Bot commented Apr 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.01%. Comparing base (224caba) to head (f1dad35).
⚠️ Report is 31 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #62530      +/-   ##
==========================================
+ Coverage   89.99%   90.01%   +0.01%     
==========================================
  Files         785      785              
  Lines      268715   269377     +662     
  Branches    51200    51313     +113     
==========================================
+ Hits       241823   242472     +649     
+ Misses      17432    17401      -31     
- Partials     9460     9504      +44     
Files with missing lines Coverage Δ
lib/internal/modules/esm/loader.js 99.90% <100.00%> (+0.09%) ⬆️
lib/internal/modules/esm/module_job.js 99.59% <100.00%> (+0.32%) ⬆️
lib/internal/modules/run_main.js 100.00% <100.00%> (ø)

... and 45 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mcollina mcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@joyeecheung

joyeecheung commented Apr 1, 2026

Copy link
Copy Markdown
Member

The PR description says there is an improvement but the number shows a regression?

Although I don't think "a flat graph importing hundreds/thousands of modules" is a representative use case, so a regression probably doesn't matter all that much anyway. A more typical graph probably consists of a lot of nodes each with a dozen or so imports..

Comment thread lib/internal/modules/run_main.js Outdated
Comment thread test/es-module/test-esm-sync-import.mjs Outdated
@GeoffreyBooth
GeoffreyBooth force-pushed the synchronously-load-most-es-modules branch from 9a7728c to 0aa5399 Compare April 2, 2026 14:19
@GeoffreyBooth

Copy link
Copy Markdown
Member Author

The PR description says there is an improvement but the number shows a regression?

My apologies, I ran the benchmark where the new binary was built with --node-builtin-modules-path "$(pwd)", so it was running much slower due to loading the built-in modules from the current path. When I ran the benchmark again with a properly built binary, the results are nearly indistinguishable from main.

A more typical graph probably consists of a lot of nodes each with a dozen or so imports.

I updated the benchmark to create a tree with 10 imports per node, as large as necessary to match the desired size of the graph. I updated the PR description with the new results. Basically, they’re inconclusive, as you might expect for such a small change. Promises just don’t add much overhead.

@GeoffreyBooth
GeoffreyBooth force-pushed the synchronously-load-most-es-modules branch from 2958720 to e5294fb Compare April 2, 2026 18:47

@JakobJingleheimer JakobJingleheimer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM and sounds right. Would be great to get 19 promises → 1 😁

Comment thread lib/internal/modules/esm/loader.js Outdated
@GeoffreyBooth
GeoffreyBooth force-pushed the synchronously-load-most-es-modules branch from e5294fb to f8823ee Compare May 25, 2026 05:49

@mcollina mcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@mcollina mcollina added the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label May 25, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label May 25, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has been marked as stale due to 90 days of inactivity.
It will be automatically closed in 30 days if no further activity occurs. If this is still relevant, please leave a comment or update it to keep it open.

@github-actions github-actions Bot added the stale Issues and PRs marked stale due to inactivity and scheduled for automatic closure. label Aug 24, 2026
@GeoffreyBooth GeoffreyBooth added never-stale Issues and PRs exempt from automated stale handling. and removed stale Issues and PRs marked stale due to inactivity and scheduled for automatic closure. labels Aug 26, 2026
@GeoffreyBooth
GeoffreyBooth force-pushed the synchronously-load-most-es-modules branch from f8823ee to ae550d4 Compare September 12, 2026 20:36
@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Benchmark GHA (esm / startup-esm-graph.js): https://github.com/nodejs/node/actions/runs/34718545834

Results

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Benchmark results:

                                             confidence improvement accuracy (*)    (**)   (***)
esm/startup-esm-graph.js n=30 modules='0250'                -0.29 %      ±10.83% ±14.27% ±18.31%
esm/startup-esm-graph.js n=30 modules='0500'                 0.29 %       ±8.96% ±11.81% ±15.16%
esm/startup-esm-graph.js n=30 modules='1000'                 0.66 %       ±7.71% ±10.16% ±13.04%
esm/startup-esm-graph.js n=30 modules='2000'                 1.63 %       ±7.79% ±10.27% ±13.18%

Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 4 comparisons, you can thus
expect the following amount of false-positive results:
  0.20 false positives, when considering a   5% risk acceptance (*, **, ***),
  0.04 false positives, when considering a   1% risk acceptance (**, ***),
  0.00 false positives, when considering a 0.1% risk acceptance (***)

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Signed-off-by: Geoffrey Booth <webadmin@geoffreybooth.com>
Signed-off-by: Geoffrey Booth <webadmin@geoffreybooth.com>
…th a branching factor of 10

Signed-off-by: Geoffrey Booth <webadmin@geoffreybooth.com>
Signed-off-by: Geoffrey Booth <webadmin@geoffreybooth.com>
Signed-off-by: Geoffrey Booth <webadmin@geoffreybooth.com>
Signed-off-by: Geoffrey Booth <webadmin@geoffreybooth.com>
Previously the pause was set up in ModuleLoader, which forced the entry
point onto the async ModuleJob and paused during instantiation, landing
the debugger on internal frames or on a dependency rather than on the
entry point itself.

Move the handling into ModuleJobSync, where the root module is actually
evaluated. Direct dependencies are pre-evaluated first so that V8 skips
them and stops at the entry point's first executable line. Both the
synchronous path and the top-level await fallback are covered.

Signed-off-by: Geoffrey Booth <webadmin@geoffreybooth.com>
An uncaught error thrown by an ES module entry point now escapes through
ModuleJobSync's evaluateSync() instead of the async triggerUncaughtException
path in run_main, so the internal frame shown above the error changes.

Signed-off-by: Geoffrey Booth <webadmin@geoffreybooth.com>
@GeoffreyBooth
GeoffreyBooth force-pushed the synchronously-load-most-es-modules branch from ae550d4 to f1dad35 Compare September 13, 2026 04:02
@GeoffreyBooth GeoffreyBooth added the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Sep 13, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@panva panva removed the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Sep 14, 2026
@panva panva added the author ready PRs with CI started, the required approvals, and no outstanding review comments. label Sep 14, 2026
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Benchmark GHA (esm / startup-esm-graph): https://github.com/nodejs/node/actions/runs/34828910699

Results

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Benchmark results:

                                             confidence improvement accuracy (*)    (**)   (***)
esm/startup-esm-graph.js n=30 modules='0250'                 0.98 %      ±11.94% ±15.74% ±20.19%
esm/startup-esm-graph.js n=30 modules='0500'                 0.94 %       ±9.85% ±12.98% ±16.66%
esm/startup-esm-graph.js n=30 modules='1000'                -0.62 %       ±8.88% ±11.71% ±15.02%
esm/startup-esm-graph.js n=30 modules='2000'                -0.63 %       ±9.41% ±12.40% ±15.91%

Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 4 comparisons, you can thus
expect the following amount of false-positive results:
  0.20 false positives, when considering a   5% risk acceptance (*, **, ***),
  0.04 false positives, when considering a   1% risk acceptance (**, ***),
  0.00 false positives, when considering a 0.1% risk acceptance (***)

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@GeoffreyBooth

Copy link
Copy Markdown
Member Author

@joyeecheung Do you want to take another look at this one?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author ready PRs with CI started, the required approvals, and no outstanding review comments. esm Issues and PRs related to the ECMAScript Modules implementation. module Issues and PRs related to the module subsystem. needs-ci PRs that need a full CI run. never-stale Issues and PRs exempt from automated stale handling.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants