From 8b96c41392f740e37dc535ed6072d479631701de Mon Sep 17 00:00:00 2001 From: Jason Jean Date: Fri, 17 Apr 2026 08:53:59 -0400 Subject: [PATCH 1/6] fix(core): speed up nx --version by avoiding heavy imports on the fast path The bin/nx.ts entry point eagerly imported the daemon client, dotenv loader, analytics + perf-logging stack, and init-local for every invocation, even trivial ones like --version. That added ~225ms of unnecessary module load time before the first instruction of main() ran. This commit: - Lazy-loads those heavy modules inside the code paths that actually need them (cloud commands, local install handoff, etc.). - Adds a --version fast path at the top of main() that exits before any heavy module is touched. - Makes src/utils/perf-logging.ts lazy-require analytics / daemon logger inside the PerformanceObserver callback, so simply importing the module no longer pulls in the native binding (~63ms saved). - Updates the bench:version benchmark (and friends) to call ./node_modules/.bin/nx directly instead of pnpm exec nx, removing ~220ms of pnpm wrapper overhead from the measurement. Result for nx --version: - nx-only contribution: ~220ms -> ~10ms - end-to-end (./node_modules/.bin/nx --version): ~260ms -> ~49ms --- benchmarks/goals.json | 4 +- benchmarks/package.json | 10 ++-- packages/nx/bin/nx.ts | 46 +++++++++++---- packages/nx/src/utils/perf-logging.ts | 80 +++++++++++++-------------- 4 files changed, 81 insertions(+), 59 deletions(-) diff --git a/benchmarks/goals.json b/benchmarks/goals.json index 8f7c0feb9c90b1..a58fdf0deb1baa 100644 --- a/benchmarks/goals.json +++ b/benchmarks/goals.json @@ -9,9 +9,9 @@ "max": 0.3 }, "copy-warm": { - "max": 1.0 + "max": 0.5 }, "build-warm": { - "max": 5.0 + "max": 0.75 } } diff --git a/benchmarks/package.json b/benchmarks/package.json index e64899ecf54c73..4f9cf24b9750b8 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -7,11 +7,11 @@ }, "scripts": { "run": "tsx run-benchmarks.ts", - "bench:version": "NX_NO_CLOUD=true hyperfine --show-output --setup 'pnpm exec nx reset' --warmup 1 --min-runs 10 --export-json results-version.json 'pnpm exec nx --version'", - "bench:show-projects": "NX_NO_CLOUD=true hyperfine --show-output --setup 'pnpm exec nx reset' --warmup 1 --min-runs 5 --export-json results-show-projects.json 'pnpm exec nx show projects --tui=false'", - "bench:cat-warm": "NX_NO_CLOUD=true hyperfine --show-output --setup 'pnpm exec nx reset' --warmup 1 --min-runs 5 --export-json results-cat-warm.json 'pnpm exec nx run-many -t cat --tui=false'", - "bench:copy-warm": "NX_NO_CLOUD=true hyperfine --show-output --setup 'pnpm exec nx reset' --warmup 1 --min-runs 5 --export-json results-copy-warm.json 'pnpm exec nx run-many -t copy --tui=false'", - "bench:build-warm": "NX_NO_CLOUD=true hyperfine --setup 'pnpm exec nx reset' --warmup 1 --min-runs 5 --export-json results-build-warm.json 'pnpm exec nx run-many -t build --tui=true --tui-auto-exit'" + "bench:version": "NX_NO_CLOUD=true hyperfine --show-output --setup 'node ../packages/nx/dist/bin/nx.js reset' --warmup 1 --min-runs 10 --export-json results-version.json 'node ../packages/nx/dist/bin/nx.js --version'", + "bench:show-projects": "NX_NO_CLOUD=true hyperfine --show-output --setup 'node ../packages/nx/dist/bin/nx.js reset' --warmup 1 --min-runs 5 --export-json results-show-projects.json 'node ../packages/nx/dist/bin/nx.js show projects --tui=false'", + "bench:cat-warm": "NX_NO_CLOUD=true hyperfine --show-output --setup 'node ../packages/nx/dist/bin/nx.js reset' --warmup 1 --min-runs 5 --export-json results-cat-warm.json 'node ../packages/nx/dist/bin/nx.js run-many -t cat --tui=false'", + "bench:copy-warm": "NX_NO_CLOUD=true hyperfine --show-output --setup 'node ../packages/nx/dist/bin/nx.js reset' --warmup 1 --min-runs 5 --export-json results-copy-warm.json 'node ../packages/nx/dist/bin/nx.js run-many -t copy --tui=false'", + "bench:build-warm": "NX_NO_CLOUD=true hyperfine --setup 'node ../packages/nx/dist/bin/nx.js reset' --warmup 1 --min-runs 5 --export-json results-build-warm.json 'node ../packages/nx/dist/bin/nx.js run-many -t build --tui=true --tui-auto-exit'" }, "nx": { "targets": { diff --git a/packages/nx/bin/nx.ts b/packages/nx/bin/nx.ts index 67059e9cc67007..def90a31c3af2e 100644 --- a/packages/nx/bin/nx.ts +++ b/packages/nx/bin/nx.ts @@ -13,8 +13,6 @@ import { WorkspaceTypeAndRoot, } from '../src/utils/find-workspace-root'; import * as pc from 'picocolors'; -import { loadRootEnvFiles } from '../src/utils/dotenv'; -import { initLocal } from './init-local'; import { output } from '../src/utils/output'; import { getNxInstallationPath, @@ -26,13 +24,11 @@ import { execSync } from 'child_process'; import { createRequire } from 'module'; import { extname, join } from 'path'; import { existsSync } from 'fs'; -import { assertSupportedPlatform } from '../src/native/assert-supported-platform'; import { performance } from 'perf_hooks'; -import { setupWorkspaceContext } from '../src/utils/workspace-context'; -import { daemonClient } from '../src/daemon/client/client'; -import { removeDbConnections } from '../src/utils/db-connection'; -import { ensureAnalyticsPreferenceSet } from '../src/utils/analytics-prompt'; -import { flushAnalytics, startAnalytics } from '../src/analytics'; +// Register the performance observer as early as possible so any +// `performance.mark` / `measure` anywhere downstream is captured. The module +// is side-effect only and its heavy deps (analytics, daemon logger) are +// lazy-loaded inside the observer callback, so the import itself is cheap. import '../src/utils/perf-logging'; const isTsExt = extname(__filename).endsWith('.ts'); @@ -45,12 +41,18 @@ async function main() { process.argv[2] !== '--help' && process.argv[2] !== 'reset' ) { + const { + assertSupportedPlatform, + } = require('../src/native/assert-supported-platform'); assertSupportedPlatform(); } const workspace = findWorkspaceRoot(process.cwd()); - if (workspace) { + // --version doesn't need any env / daemon / analytics state — skip dotenv + // loading (and the heavy modules it would pull in). + if (workspace && process.argv[2] !== '--version') { + const { loadRootEnvFiles } = require('../src/utils/dotenv'); performance.mark('loading dotenv files:start'); loadRootEnvFiles(workspace.dir); performance.mark('loading dotenv files:end'); @@ -107,7 +109,12 @@ async function main() { // this file is already in the local workspace if (isNxCloudCommand(process.argv[2])) { + const { daemonClient } = + require('../src/daemon/client/client') as typeof import('../src/daemon/client/client'); if (!daemonClient.enabled() && workspace !== null) { + const { + setupWorkspaceContext, + } = require('../src/utils/workspace-context'); setupWorkspaceContext(workspace.dir); } await initAnalytics(); @@ -115,10 +122,16 @@ async function main() { process.env.NX_DAEMON = 'false'; require('nx/src/command-line/nx-commands').commandsObject.argv; } else if (isLocalInstall) { + const { daemonClient } = + require('../src/daemon/client/client') as typeof import('../src/daemon/client/client'); if (!daemonClient.enabled() && workspace !== null) { + const { + setupWorkspaceContext, + } = require('../src/utils/workspace-context'); setupWorkspaceContext(workspace.dir); } await initAnalytics(); + const { initLocal } = require('./init-local'); await initLocal(workspace); } else if (localNx) { // Nx is being run from globally installed CLI - hand off to the local @@ -221,6 +234,10 @@ function isNxCloudCommand(command: string): boolean { } async function initAnalytics() { + const { + ensureAnalyticsPreferenceSet, + } = require('../src/utils/analytics-prompt'); + const { startAnalytics } = require('../src/analytics'); try { await ensureAnalyticsPreferenceSet(); } catch {} @@ -340,11 +357,18 @@ const getLatestVersionOfNx = ((fn: () => string) => { })(_getLatestVersionOfNx); process.on('exit', () => { - removeDbConnections(); + // Only clean up if db-connection was actually loaded during this run. + const cached = require.cache[require.resolve('../src/utils/db-connection')]; + if (cached) { + cached.exports.removeDbConnections(); + } }); main().catch((error) => { console.error(error); - flushAnalytics(); + const cached = require.cache[require.resolve('../src/analytics')]; + if (cached) { + cached.exports.flushAnalytics(); + } process.exit(1); }); diff --git a/packages/nx/src/utils/perf-logging.ts b/packages/nx/src/utils/perf-logging.ts index 90924843682e0b..34c48f48bafee4 100644 --- a/packages/nx/src/utils/perf-logging.ts +++ b/packages/nx/src/utils/perf-logging.ts @@ -1,10 +1,6 @@ import { PerformanceObserver } from 'perf_hooks'; -import { customDimensions, reportEvent } from '../analytics'; -import type { EventParameters } from '../analytics'; import type { TrackedDetail } from './perf-hooks'; -import { isOnDaemon } from '../daemon/is-on-daemon'; -import { serverLogger } from '../daemon/logger'; function isTrackedDetail(detail: unknown): detail is TrackedDetail { return ( @@ -14,41 +10,43 @@ function isTrackedDetail(detail: unknown): detail is TrackedDetail { ); } -const dimensionValues = customDimensions - ? new Set(Object.values(customDimensions)) - : null; - -let initialized = false; - -if (!initialized) { - initialized = true; - - const obs = new PerformanceObserver((list) => { - for (const entry of list.getEntries()) { - const { detail } = entry; - - if (process.env.NX_PERF_LOGGING === 'true') { - const message = `Time taken for '${entry.name}' ${entry.duration}ms`; - if (isOnDaemon()) { - serverLogger.log(message); - } else { - console.log(message); - } - } - - if (isTrackedDetail(detail) && dimensionValues) { - const { track, ...rest } = detail; - const eventParameters: EventParameters = { - [customDimensions.duration]: entry.duration, - }; - for (const [key, value] of Object.entries(rest)) { - if (dimensionValues.has(key)) { - eventParameters[key] = value; - } - } - reportEvent(entry.name, eventParameters); - } +new PerformanceObserver((list) => { + const entries = list.getEntries(); + const logEnabled = process.env.NX_PERF_LOGGING === 'true'; + const tracked = entries.filter((e) => isTrackedDetail(e.detail)); + + // Short-circuit before loading analytics / daemon logger (~60ms of native + // binding + module init) when there's nothing to do. + if (!logEnabled && tracked.length === 0) return; + + if (logEnabled) { + const { isOnDaemon } = + require('../daemon/is-on-daemon') as typeof import('../daemon/is-on-daemon'); + const { serverLogger } = + require('../daemon/logger') as typeof import('../daemon/logger'); + const log = isOnDaemon() + ? (msg: string) => serverLogger.log(msg) + : console.log; + for (const entry of entries) { + log(`Time taken for '${entry.name}' ${entry.duration}ms`); } - }); - obs.observe({ entryTypes: ['measure'] }); -} + } + + if (tracked.length === 0) return; + + const { customDimensions, reportEvent } = + require('../analytics') as typeof import('../analytics'); + if (!customDimensions) return; + const dimensionValues = new Set(Object.values(customDimensions)); + + for (const entry of tracked) { + const { track, ...rest } = entry.detail as TrackedDetail; + const params: import('../analytics').EventParameters = { + [customDimensions.duration]: entry.duration, + }; + for (const [key, value] of Object.entries(rest)) { + if (dimensionValues.has(key)) params[key] = value; + } + reportEvent(entry.name, params); + } +}).observe({ entryTypes: ['measure'] }); From 001b9f18b23bdd181392b7bfa6babc0b60452476 Mon Sep 17 00:00:00 2001 From: FrozenPandaz Date: Mon, 20 Apr 2026 15:02:48 -0400 Subject: [PATCH 2/6] fix(core): avoid require.resolve in nx bin exit handlers The previous fast-path refactor guarded cleanup with require.cache[require.resolve(...)] so modules would only be cleaned up if they had actually been loaded. But require.resolve throws synchronously when the module can't be resolved, which broke the exit flow in published nx installs (observed as "Cannot find module '../src/utils/db-connection'" in create-nx-workspace e2e tests and masked the real error). - Move the db-connection exit handler into db-connection.ts so it only registers when the module is actually imported. - Track analyticsStarted via a plain flag in bin/nx.ts instead of probing require.cache with require.resolve. --- packages/nx/bin/nx.ts | 15 ++++----------- packages/nx/src/utils/db-connection.ts | 2 ++ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/nx/bin/nx.ts b/packages/nx/bin/nx.ts index def90a31c3af2e..9410d35902a2ad 100644 --- a/packages/nx/bin/nx.ts +++ b/packages/nx/bin/nx.ts @@ -233,6 +233,7 @@ function isNxCloudCommand(command: string): boolean { return nxCloudCommands.includes(command); } +let analyticsStarted = false; async function initAnalytics() { const { ensureAnalyticsPreferenceSet, @@ -242,6 +243,7 @@ async function initAnalytics() { await ensureAnalyticsPreferenceSet(); } catch {} await startAnalytics(); + analyticsStarted = true; } function handleMissingLocalInstallation(detectedWorkspaceRoot: string | null) { @@ -356,19 +358,10 @@ const getLatestVersionOfNx = ((fn: () => string) => { return () => cache || (cache = fn()); })(_getLatestVersionOfNx); -process.on('exit', () => { - // Only clean up if db-connection was actually loaded during this run. - const cached = require.cache[require.resolve('../src/utils/db-connection')]; - if (cached) { - cached.exports.removeDbConnections(); - } -}); - main().catch((error) => { console.error(error); - const cached = require.cache[require.resolve('../src/analytics')]; - if (cached) { - cached.exports.flushAnalytics(); + if (analyticsStarted) { + require('../src/analytics').flushAnalytics(); } process.exit(1); }); diff --git a/packages/nx/src/utils/db-connection.ts b/packages/nx/src/utils/db-connection.ts index baceb729a69e66..675d241c3f0b95 100644 --- a/packages/nx/src/utils/db-connection.ts +++ b/packages/nx/src/utils/db-connection.ts @@ -70,6 +70,8 @@ export function removeDbConnections() { dbConnectionMap.clear(); } +process.on('exit', removeDbConnections); + function getEntryOrSet( map: Map, key: TKey, From 636507a7ba8cbe0eda8d8ca5c22a642eeaf20dc7 Mon Sep 17 00:00:00 2001 From: FrozenPandaz Date: Mon, 20 Apr 2026 15:35:32 -0400 Subject: [PATCH 3/6] chore(core): switch lazy loads in bin/nx.ts from require() to import() Gives proper TypeScript types on the lazily-loaded modules without 'as typeof import(...)' casts. No measurable perf difference on the --version fast path since it short-circuits before any of these fire. Directory imports (analytics/) need an explicit /index.js because dynamic import() uses ESM resolution rules. --- packages/nx/bin/nx.ts | 51 ++++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/packages/nx/bin/nx.ts b/packages/nx/bin/nx.ts index 9410d35902a2ad..a551a84df2c0f1 100644 --- a/packages/nx/bin/nx.ts +++ b/packages/nx/bin/nx.ts @@ -41,9 +41,9 @@ async function main() { process.argv[2] !== '--help' && process.argv[2] !== 'reset' ) { - const { - assertSupportedPlatform, - } = require('../src/native/assert-supported-platform'); + const { assertSupportedPlatform } = await import( + '../src/native/assert-supported-platform.js' + ); assertSupportedPlatform(); } @@ -52,7 +52,7 @@ async function main() { // --version doesn't need any env / daemon / analytics state — skip dotenv // loading (and the heavy modules it would pull in). if (workspace && process.argv[2] !== '--version') { - const { loadRootEnvFiles } = require('../src/utils/dotenv'); + const { loadRootEnvFiles } = await import('../src/utils/dotenv.js'); performance.mark('loading dotenv files:start'); loadRootEnvFiles(workspace.dir); performance.mark('loading dotenv files:end'); @@ -72,7 +72,7 @@ async function main() { (process.argv[2] === 'graph' && !workspace) ) { process.env.NX_DAEMON = 'false'; - require('nx/src/command-line/nx-commands').commandsObject.argv; + (await import('nx/src/command-line/nx-commands')).commandsObject.argv; } else { // polyfill rxjs observable to avoid issues with multiple version of Observable installed in node_modules // https://twitter.com/BenLesh/status/1192478226385428483?s=20 @@ -109,29 +109,27 @@ async function main() { // this file is already in the local workspace if (isNxCloudCommand(process.argv[2])) { - const { daemonClient } = - require('../src/daemon/client/client') as typeof import('../src/daemon/client/client'); + const { daemonClient } = await import('../src/daemon/client/client.js'); if (!daemonClient.enabled() && workspace !== null) { - const { - setupWorkspaceContext, - } = require('../src/utils/workspace-context'); + const { setupWorkspaceContext } = await import( + '../src/utils/workspace-context.js' + ); setupWorkspaceContext(workspace.dir); } await initAnalytics(); // nx-cloud commands can run without local Nx installation process.env.NX_DAEMON = 'false'; - require('nx/src/command-line/nx-commands').commandsObject.argv; + (await import('nx/src/command-line/nx-commands')).commandsObject.argv; } else if (isLocalInstall) { - const { daemonClient } = - require('../src/daemon/client/client') as typeof import('../src/daemon/client/client'); + const { daemonClient } = await import('../src/daemon/client/client.js'); if (!daemonClient.enabled() && workspace !== null) { - const { - setupWorkspaceContext, - } = require('../src/utils/workspace-context'); + const { setupWorkspaceContext } = await import( + '../src/utils/workspace-context.js' + ); setupWorkspaceContext(workspace.dir); } await initAnalytics(); - const { initLocal } = require('./init-local'); + const { initLocal } = await import('./init-local.js'); await initLocal(workspace); } else if (localNx) { // Nx is being run from globally installed CLI - hand off to the local @@ -140,9 +138,9 @@ async function main() { warnIfUsingOutdatedGlobalInstall(GLOBAL_NX_VERSION, LOCAL_NX_VERSION); if (localNx.includes('.nx')) { const nxWrapperPath = localNx.replace(/\.nx.*/, '.nx/') + 'nxw.js'; - require(nxWrapperPath); + await import(nxWrapperPath); } else { - require(localNx); + await import(localNx); } } } @@ -235,10 +233,10 @@ function isNxCloudCommand(command: string): boolean { let analyticsStarted = false; async function initAnalytics() { - const { - ensureAnalyticsPreferenceSet, - } = require('../src/utils/analytics-prompt'); - const { startAnalytics } = require('../src/analytics'); + const { ensureAnalyticsPreferenceSet } = await import( + '../src/utils/analytics-prompt.js' + ); + const { startAnalytics } = await import('../src/analytics/index.js'); try { await ensureAnalyticsPreferenceSet(); } catch {} @@ -358,10 +356,13 @@ const getLatestVersionOfNx = ((fn: () => string) => { return () => cache || (cache = fn()); })(_getLatestVersionOfNx); -main().catch((error) => { +main().catch(async (error) => { console.error(error); if (analyticsStarted) { - require('../src/analytics').flushAnalytics(); + // analyticsStarted implies '../src/analytics' is already in the module + // cache, so this resolves from cache without any disk work. + const { flushAnalytics } = await import('../src/analytics/index.js'); + flushAnalytics(); } process.exit(1); }); From e53cc424457e707fcae72ab1c827cdfbeeeb3bd6 Mon Sep 17 00:00:00 2001 From: "nx-cloud[bot]" <71083854+nx-cloud[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:46:26 +0000 Subject: [PATCH 4/6] chore(core): switch lazy loads in bin/nx.ts from require() to import() [Self-Healing CI Rerun] From e23d326407de4e85a15f691fd40755cb01ffcf55 Mon Sep 17 00:00:00 2001 From: FrozenPandaz Date: Mon, 20 Apr 2026 18:11:55 -0400 Subject: [PATCH 5/6] chore(core): unexport removeDbConnections It's only used internally by the self-registered exit handler now, so there's no reason to expose it. --- packages/nx/src/utils/db-connection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nx/src/utils/db-connection.ts b/packages/nx/src/utils/db-connection.ts index 675d241c3f0b95..e389e4034dde62 100644 --- a/packages/nx/src/utils/db-connection.ts +++ b/packages/nx/src/utils/db-connection.ts @@ -63,7 +63,7 @@ export function getLocalDbConnection( return connection; } -export function removeDbConnections() { +function removeDbConnections() { for (const connection of dbConnectionMap.values()) { closeDbConnection(connection); } From 75a7b1966e1979d6c8ac906112f1ca49f2374ce0 Mon Sep 17 00:00:00 2001 From: FrozenPandaz Date: Mon, 20 Apr 2026 18:12:52 -0400 Subject: [PATCH 6/6] chore(core): drop explicit removeDbConnections() call from daemon shutdown The db-connection module now self-registers a process 'exit' handler that cleans up connections, so the daemon shutdown path doesn't need to call it explicitly. --- packages/nx/src/daemon/server/shutdown-utils.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/nx/src/daemon/server/shutdown-utils.ts b/packages/nx/src/daemon/server/shutdown-utils.ts index ff25a0cbe89219..e8d1691d95fbf2 100644 --- a/packages/nx/src/daemon/server/shutdown-utils.ts +++ b/packages/nx/src/daemon/server/shutdown-utils.ts @@ -8,7 +8,6 @@ import { DaemonProjectGraphError, ProjectGraphError, } from '../../project-graph/error-types'; -import { removeDbConnections } from '../../utils/db-connection'; import { cleanupPlugins } from '../../project-graph/plugins/get-plugins'; import { MESSAGE_END_SEQ } from '../../utils/consume-messages-from-socket'; import { cleanupLatestNx } from './latest-nx'; @@ -144,8 +143,6 @@ async function performShutdown( deleteDaemonJsonProcessCache(); cleanupPlugins(); - removeDbConnections(); - // Clean up shared latest Nx installation cleanupLatestNx();