perf(core): optimize TypeScript compiler caching and hash operations
Current Behavior
1. TypeScript Compiler Host Uses Single Global Cache
// packages/nx/src/plugins/js/utils/typescript.ts (lines 52-70)
let compilerHost: { host, options, moduleResolutionCache }; // SINGLE GLOBAL
export function resolveModuleByImport(importExpr, filePath, tsConfigPath) {
compilerHost = compilerHost || getCompilerHost(tsConfigPath); // First call wins!
// ...
}
Problem Visualization:
┌─────────────────────────────────────────────────────────────────────────┐
│ CURRENT: Single Global Cache │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Call 1: resolveModuleByImport("lodash", "apps/web/src/app.ts", │
│ "apps/web/tsconfig.json") │
│ └─> Creates compilerHost with apps/web/tsconfig.json │
│ │
│ Call 2: resolveModuleByImport("shared", "libs/api/src/index.ts", │
│ "libs/api/tsconfig.json") │
│ └─> REUSES apps/web/tsconfig.json! ❌ WRONG CONFIG │
│ │
│ Call 3: resolveModuleByImport("utils", "libs/core/src/index.ts", │
│ "libs/core/tsconfig.json") │
│ └─> STILL uses apps/web/tsconfig.json! ❌ WRONG CONFIG │
│ │
│ Result: All projects resolve modules using the FIRST tsconfig │
│ encountered, leading to incorrect dependency resolution │
│ │
└─────────────────────────────────────────────────────────────────────────┘
2. hashObject Uses JSON.stringify for All Values
// packages/nx/src/hasher/file-hasher.ts (lines 7-17)
export function hashObject(obj: object): string {
for (const key of Object.keys(obj ?? {}).sort()) {
parts.push(key);
parts.push(JSON.stringify(obj[key])); // Even for strings/numbers!
}
return hashArray(parts);
}
Cost Analysis:
┌─────────────────────────────────────────────────────────────────┐
│ JSON.stringify Cost by Value Type │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Value Type JSON.stringify Optimized │
│ ──────────── ───────────────── ──────────────── │
│ "hello" JSON.stringify() Direct use │
│ → creates new → zero allocation │
│ string + quotes │
│ │
│ 42 JSON.stringify() String(42) │
│ → "42" → "42" │
│ (full JSON engine) (simple coercion) │
│ │
│ true JSON.stringify() String(true) │
│ → "true" → "true" │
│ │
│ {nested: obj} JSON.stringify() JSON.stringify() │
│ (necessary) (still needed) │
│ │
│ Typical package.json deps (30 entries): │
│ - 28 string values → 28 unnecessary JSON.stringify calls │
│ - 2 object values → 2 necessary JSON.stringify calls │
│ │
│ Savings: ~93% fewer JSON.stringify calls for typical usage │
└─────────────────────────────────────────────────────────────────┘
3. Path Mappings Comparison Uses JSON.stringify Per Entry
// packages/nx/src/project-graph/nx-deps-cache.ts (lines 294-305)
Object.keys(cache.pathMappings).some((t) => {
const cached = JSON.stringify(cache.pathMappings[t]); // For each mapping
const notCached = JSON.stringify(tsConfig.paths[t]); // Stringify both
return cached !== notCached;
})
Performance Impact:
┌─────────────────────────────────────────────────────────────────┐
│ Path Mappings Comparison (typical monorepo) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Typical tsconfig.json paths (50 entries): │
│ │
│ "@myorg/shared": ["libs/shared/src/index.ts"] │
│ "@myorg/utils": ["libs/utils/src/index.ts"] │
│ "@myorg/api": ["libs/api/src/index.ts"] │
│ ... (47 more entries) │
│ │
│ BEFORE: 50 × 2 = 100 JSON.stringify calls per validation │
│ AFTER: 50 × direct array comparison (length + element ===) │
│ │
│ ┌──────────────────┬──────────────────┬──────────────────┐ │
│ │ Operation │ Before │ After │ │
│ ├──────────────────┼──────────────────┼──────────────────┤ │
│ │ Function calls │ 100 │ 0 │ │
│ │ String allocs │ 100 │ 0 │ │
│ │ Comparisons │ 50 string │ ~100 primitive │ │
│ └──────────────────┴──────────────────┴──────────────────┘ │
│ │
│ JSON.stringify is ~10-100x slower than primitive comparison │
└─────────────────────────────────────────────────────────────────┘
Expected Behavior
1. Per-tsconfig Compiler Host Caching
┌─────────────────────────────────────────────────────────────────────────┐
│ NEW: Per-tsconfig Cache (Map) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ compilerHostCache = Map<string, CompilerHost> │
│ │
│ Call 1: resolveModuleByImport(..., "apps/web/tsconfig.json") │
│ └─> Cache MISS → Create & store for "apps/web/tsconfig.json" │
│ │
│ Call 2: resolveModuleByImport(..., "libs/api/tsconfig.json") │
│ └─> Cache MISS → Create & store for "libs/api/tsconfig.json" │
│ ✅ Correct config used! │
│ │
│ Call 3: resolveModuleByImport(..., "apps/web/tsconfig.json") │
│ └─> Cache HIT → Reuse "apps/web/tsconfig.json" host │
│ ✅ Correct config reused! │
│ │
│ Each tsconfig gets its own compiler host with correct options │
│ │
└─────────────────────────────────────────────────────────────────────────┘
2. Optimized hashObject
┌─────────────────────────────────────────────────────────────────┐
│ hashObject Optimization Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Input: { name: "lodash", version: "4.17.21", dev: true } │
│ │
│ BEFORE: AFTER: │
│ ──────────────────────── ────────────────────── │
│ JSON.stringify("lodash") "lodash" (direct) │
│ JSON.stringify("4.17.21") "4.17.21" (direct) │
│ JSON.stringify(true) String(true) → "true" │
│ │
│ Parts array (before): Parts array (after): │
│ ["name", "\"lodash\"", ["name", "lodash", │
│ "version", "\"4.17.21\"", "version", "4.17.21", │
│ "dev", "true"] "dev", "true"] │
│ │
│ 3 JSON.stringify calls 0 JSON.stringify calls │
│ + 3 string allocations + 1 String() coercion │
│ │
└─────────────────────────────────────────────────────────────────┘
3. Direct Array Comparison for Path Mappings
┌─────────────────────────────────────────────────────────────────┐
│ Path Mapping Comparison Algorithm │
├─────────────────────────────────────────────────────────────────┤
│ │
│ cached: ["libs/shared/src/index.ts"] │
│ current: ["libs/shared/src/index.ts"] │
│ │
│ BEFORE: │
│ JSON.stringify(cached) === JSON.stringify(current) │
│ '["libs/shared/src/index.ts"]' === '["libs/shared/src/index.ts"]' │
│ (2 full array serializations, then string compare) │
│ │
│ AFTER: │
│ 1. cached.length === current.length (1 === 1) ✓ │
│ 2. cached[0] === current[0] ("..." === "...") ✓ │
│ (2 primitive comparisons, zero allocations) │
│ │
│ For mismatched arrays, fails fast: │
│ - Different length? Return immediately (no stringify) │
│ - Different element? Return at first mismatch │
│ │
└─────────────────────────────────────────────────────────────────┘
Summary of Changes
| File | Change | Impact |
|---|---|---|
typescript.ts |
Single global → Map per tsconfig | Fixes incorrect module resolution bug |
file-hasher.ts |
Skip JSON.stringify for primitives | ~93% fewer stringify calls |
nx-deps-cache.ts |
Direct array comparison | Zero allocations for path validation |
Why Accept This PR?
1. Bug Fix: The TypeScript compiler host bug causes real issues
┌─────────────────────────────────────────────────────────────────┐
│ Real-World Impact │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Monorepo Structure: │
│ ├── apps/ │
│ │ ├── web/tsconfig.json (strict: true, target: ES2022) │
│ │ └── mobile/tsconfig.json (strict: false, target: ES2020) │
│ └── libs/ │
│ └── shared/tsconfig.json (composite: true) │
│ │
│ BEFORE: All projects use web's tsconfig options │
│ - mobile gets wrong target, strict settings │
│ - shared loses composite flag benefits │
│ - Module resolution may fail for projects with different │
│ baseUrl or paths configurations │
│ │
│ AFTER: Each project uses its correct tsconfig │
│ - Accurate dependency detection │
│ - Correct module resolution per project │
│ │
└─────────────────────────────────────────────────────────────────┘
2. Performance: Called on every Nx command
┌─────────────────────────────────────────────────────────────────┐
│ Frequency of Operations │
├─────────────────────────────────────────────────────────────────┤
│ │
│ hashObject: │
│ - Called when hashing npm dependencies │
│ - Called when computing external nodes hash │
│ - Called during plugin configuration hashing │
│ │
│ shouldRecomputeWholeGraph (path mappings check): │
│ - Called on EVERY nx command that uses project graph │
│ - nx build, nx test, nx lint, nx affected, etc. │
│ - In daemon mode: called on every file change │
│ │
│ Estimated calls per nx command: 50-200+ │
│ Estimated savings: 5-15ms per command (varies by workspace) │
│ │
└─────────────────────────────────────────────────────────────────┘
3. Zero Risk: Backward compatible changes
- Map cache is strictly more correct than global singleton
- hashObject produces identical hashes (same algorithm, different path)
- Array comparison is equivalent to JSON.stringify comparison for string arrays
Test Plan
- [x]
packages/nx/src/plugins/js/utils/typescript.spec.ts- 2 tests pass - [x]
packages/nx/src/project-graph/nx-deps-cache.spec.ts- 17 tests pass - [x] All tests use hashObject continue to pass
Related Issue(s)
Contributes to #32962
Merge Dependencies
Must be merged AFTER: #33736 Must be merged BEFORE: #33740
Deploy request for nx-docs pending review.
Visit the deploys page to approve it
| Name | Link |
|---|---|
| Latest commit | dffae2af878aa3bbfacd3da3e6cef5f5288a2de4 |
The latest updates on your projects. Learn more about Vercel for GitHub.
| Project | Deployment | Preview | Updated (UTC) |
|---|---|---|---|
| nx-dev | Preview | Dec 8, 2025 4:24am |
@adwait1290 We are very grateful for your enthusiasm to contribute, I kindly request that you please stop sending these AI assisted micro-perf PRs now. In future, please open an issue regarding your plans and do not simply send pages worth of micro PRs without open communication.
Upon deeper inspection in some cases, we have found that they are not resulting in real-world performance wins, and instead create regressions because they are not considering memory and GC overhead of the whole system.
We will work on better benchmarking infrastructure on our side to have greater confidence in CI as to whether these kinds of PRs are actually net wins but for now each individual PR requires a thorough investigation by the team and you are sending far, far too many.
To reduce noise on the repo, I am going to close this, but rest assured it will be looked at as part of our performance optimization and benchmarking effort and merged in if it creates a provable net win.
Thank you once again for your keenness to help make Nx the best it can be, we really appreciate it!
This pull request has already been merged/closed. If you experience issues related to these changes, please open a new issue referencing this pull request.