Summary
The production server re-runs several dynamic import() calls on every request. Node caches the modules, but each import() still pays ESM resolution, and when ESM loader hooks are registered (@sentry/node or OpenTelemetry via module.register()) every resolution becomes a synchronous round trip to the loader hooks thread (makeSyncRequest in node:internal/modules/esm/hooks). Profiling a production app with Sentry instrumentation, this path alone accounted for about 17% of process CPU.
Measurements
Counted with a diagnostics_channel.tracingChannel("module.import") subscriber inside the prod server of the app-basic fixture, 30 iterations over 4 routes (120 requests):
| module |
imports |
config/config-matchers.js (app-rsc-handler) |
720 (6 per request) |
config-headers.js |
150 |
metadata-route-response thunk (runs for every request, not only metadata routes) |
120 |
app-page-cache.js (app-page-dispatch) |
60 |
file-based-metadata thunk |
60 |
loadModule("ssr", "index") |
60 |
app-route-handler-dispatch thunk |
30 |
headers.js (via connection() in the server shim) |
30 |
| total |
about 10 per request |
Suggested fix
Cache the import promises. Two cases seem worth distinguishing:
- Internal framework modules (
headers.js, config-matchers, config-headers, app-page-cache, the generated entry thunks, react-dom/static.edge): memoize unconditionally at module scope. The targets are framework code, never user code, so this is safe in dev too (when the importer is invalidated, its module scope cache resets with it).
import.meta.viteRsc.loadModule("ssr" | "rsc", "index"): the target bundles user code, so memoize only when process.env.NODE_ENV === "production" to keep the per call runner import that dev HMR relies on. NODE_ENV matches the dev detection these files already use.
With this change the same measurement drops from about 10 imports per request to 0, and the prod, dev/HMR and pages router test suites stay green.
Happy to send a PR with the change. Thanks for the great project!
Summary
The production server re-runs several dynamic
import()calls on every request. Node caches the modules, but eachimport()still pays ESM resolution, and when ESM loader hooks are registered (@sentry/nodeor OpenTelemetry viamodule.register()) every resolution becomes a synchronous round trip to the loader hooks thread (makeSyncRequestinnode:internal/modules/esm/hooks). Profiling a production app with Sentry instrumentation, this path alone accounted for about 17% of process CPU.Measurements
Counted with a
diagnostics_channel.tracingChannel("module.import")subscriber inside the prod server of theapp-basicfixture, 30 iterations over 4 routes (120 requests):config/config-matchers.js(app-rsc-handler)config-headers.jsmetadata-route-responsethunk (runs for every request, not only metadata routes)app-page-cache.js(app-page-dispatch)file-based-metadatathunkloadModule("ssr", "index")app-route-handler-dispatchthunkheaders.js(viaconnection()in the server shim)Suggested fix
Cache the import promises. Two cases seem worth distinguishing:
headers.js,config-matchers,config-headers,app-page-cache, the generated entry thunks,react-dom/static.edge): memoize unconditionally at module scope. The targets are framework code, never user code, so this is safe in dev too (when the importer is invalidated, its module scope cache resets with it).import.meta.viteRsc.loadModule("ssr" | "rsc", "index"): the target bundles user code, so memoize only whenprocess.env.NODE_ENV === "production"to keep the per call runner import that dev HMR relies on.NODE_ENVmatches the dev detection these files already use.With this change the same measurement drops from about 10 imports per request to 0, and the prod, dev/HMR and pages router test suites stay green.
Happy to send a PR with the change. Thanks for the great project!