external/ag-shared/prompts/skills/nx-performance/SKILL.md
This skill encodes battle-tested patterns from AG product Nx monorepos — large-scale Nx 20 workspaces with 100-220+ projects, decomposed build targets, batch executors, and sophisticated CI pipelines. Use it both for making changes and for auditing.
| Topic | Reference file | Read when... |
|---|---|---|
| Named inputs, cache config, output declarations | references/caching-strategy.md | Modifying namedInputs, targetDefaults, cache, or outputs in nx.json or project.json |
| Build decomposition, esbuild, dev server, batch executors | references/build-and-dev.md | Changing build targets, adding new targets, modifying the dev server, or working with batch executors |
| CI caching, sharding, artifacts, concurrency | references/ci-patterns.md | Modifying GitHub Actions workflows, CI caching strategy, test sharding, or artifact sharing |
| Known bugs and fixes from AG monorepos | references/gotchas.md | Debugging unexpected cache behaviour, or auditing a repo that may have inherited older patterns |
Every Nx target that has cache: true must satisfy all five. Violating any one silently degrades cache hit rates — often to zero.
A target's inputs (files Nx hashes for the cache key) and outputs (files the target produces) must be disjoint. When they overlap, the target invalidates its own cache every time it runs.
Common violations:
src/.js alongside .ts in src/)--fix) during build, mutating source filesFix: Write generated/transformed files to dist/ or .generated/, and exclude that directory from inputs:
"buildOutputExcludes": ["!{projectRoot}/dist/**"]
Running a target twice with the same inputs must produce byte-identical outputs. Non-idempotent targets create cache entries that never validate on restore.
Common breakers: timestamps in output, non-deterministic ordering (Object.keys(), fs.readdir()), random values, absolute paths in source maps.
How to test:
nx run my-package:build
cp -r packages/my-package/dist /tmp/first-run
nx run my-package:build --skip-nx-cache
diff -r /tmp/first-run packages/my-package/dist
# Any diff = non-idempotent target
When target A produces output that target B reads, A's outputs must not land in B's input set unintentionally. This is a cross-package variant of Law 1.
Fix: Use dependentTasksOutputFiles to read only specific output files (e.g., *.d.ts) from dependencies — not their entire src/. Exclude all dist/ from the default input set.
The cache key must invalidate when any tool that transforms the output changes. This means two things:
npm tool versions — declare externalDependencies so upgrading TypeScript or esbuild invalidates the cache:
{ "externalDependencies": ["npm:typescript", "npm:esbuild"] }
Local build scripts — if the target runs a custom script (e.g., node tools/compile-sass.js), that script file must be in the target's inputs. Otherwise, changes to the script produce stale cached results:
"inputs": [
"{projectRoot}/src/**/*.scss",
"{workspaceRoot}/tools/compile-sass.js",
"buildOutputExcludes",
{ "externalDependencies": ["npm:sass"] }
]
A common mistake is to include only source files in inputs while forgetting the scripts and config files that process them.
Missing outputs means Nx caches "nothing" — the target runs, produces files, but restoring from cache doesn't restore those files.
"build:types": { "outputs": ["{options.outputPath}"] }
"lint": { "outputs": [] }
"test": { "outputs": [] }
Even targets that produce no files (lint, test) should declare "outputs": [] explicitly.
Define reusable namedInputs in nx.json so every target references a well-scoped input set. This is the single most impactful optimisation — it prevents over-invalidation (cache misses from irrelevant file changes) and under-invalidation (stale results).
Essential named inputs:
production — source files minus tests, snapshots, lint configs, and build outputsbuildOutputExcludes — !{projectRoot}/dist/** (prevents self-invalidation)sharedGlobals — root config files that affect all builds (tsconfig, esbuild config)See references/caching-strategy.md for the full hierarchy and dependency output inputs (tsDeclarations, jsOutputs, allTransitiveOutputs).
Split monolithic build into sub-targets that maximise parallelism and minimise cache invalidation:
| Target | Executor | Produces | Key benefit |
|---|---|---|---|
build | nx:noop | Nothing (aggregator) | Fan-out point, inputs: [], outputs: [] |
build:types | @nx/js:tsc | dist/types/*.d.ts | Parallel with build:package |
build:package | @nx/esbuild | dist/package/*.cjs.js + *.esm.mjs | Only rebuilds on source changes |
build:umd | @nx/esbuild | dist/umd/*.js | Consumes JS output, not source |
build:test | tsc | dist/test/** | Depends on types only, not packages |
See references/build-and-dev.md for the full pipeline including dev server and batch executors.
Be precise about dependsOn — targets should depend on exactly what they need:
// build:umd only needs JS outputs from dependencies, not types
"build:umd": { "dependsOn": ["build:package", "^build:package"] }
// build:test needs types for compilation, not packages
"build:test": { "dependsOn": ["^build:types", "build:types"] }
// test needs compiled specs + runtime, but NOT UMD bundles
"test": { "dependsOn": ["build:test"] }
Over-broad dependencies (everything depending on build) serialise the task graph unnecessarily.
Use nx:noop for fan-out targets (like build that just triggers sub-targets):
"build": {
"executor": "nx:noop",
"dependsOn": ["build:types", "build:package", "build:umd"],
"inputs": [],
"outputs": [],
"cache": true
}
inputs: [] and outputs: [] are critical. Without them, cache restoration can delete real build artifacts produced by sub-targets.
nx:run-commandsSet cache: true as the default for nx:run-commands in targetDefaults:
"targetDefaults": {
"nx:run-commands": { "cache": true }
}
Without this, every shell command target is uncached by default.
When auditing a repo for Nx optimisations, work through these checks in order. The checklist is prioritised by typical impact.
src/ or directories included in their inputs?dist/ exclusion — Is dist/ excluded from input globs? Look for buildOutputExcludes or !{projectRoot}/dist/** in namedInputs.namedInputs exist — Are they defined in nx.json and referenced by targets? The biggest miss is targets using the default {projectRoot}/**/*, which includes test files, snapshots, and dist output.production input — Does it exclude test files (!**/*.spec.*), snapshots (!**/__image_snapshots__/**), and build outputs?outputs declarations — Every cached target should declare its outputs. Missing outputs means cache restores are empty.build a monolithic target, or decomposed into types/package/umd?test depending on full build including UMD)externalDependencies — Are compiler tools (typescript, esbuild) declared on build targets?transitive: false — Are dependency output inputs using transitive: false where only direct deps are needed?cache: true default — Is nx:run-commands cached by default in targetDefaults?useLegacyCache: false — Is the newer, more efficient cache format enabled?cache: false — Search for "cache": false in project.json files and verify each is intentional.inputs: [], outputs: []?references/ci-patterns.md for the full CI audit checklist covering GHA caching, sharding, artifacts, and concurrency control.references/gotchas.md for 13 specific bugs discovered and fixed in AG product monorepos. The most common issues (check these first):
inputs: [], outputs: [].nx:run-commands with parallel: true — race conditions when commands must run sequentially. Fix: parallel: false..dependency-cruiser.js, eslint.* not in lint inputs means stale lint results after config changes.cache: false on orchestrator targets, or broaden inputs so branch switches invalidate the cache. Workaround: nx reset or --skip-nx-cache.implicitDependencies — Nx can't trace through external imports for build ordering.# Check named inputs are defined
cat nx.json | jq '.namedInputs | keys'
# Check target defaults
cat nx.json | jq '.targetDefaults | keys'
# Check which targets have cache: true/false
grep -r '"cache"' packages/*/project.json nx.json
# Check for targets that write to src/
grep -r '"command"' packages/*/project.json | grep 'src/'
# Test idempotency
nx run <package>:build && cp -r packages/<package>/dist /tmp/first-run
nx run <package>:build --skip-nx-cache
diff -r /tmp/first-run packages/<package>/dist
# List all projects (including auto-generated)
nx show projects | wc -l
# Check cache size
du -sh .nx/cache/
# Check build decomposition for a package
cat packages/<package>/project.json | jq '.targets | keys'
# Check .nxignore exists
cat .nxignore
When modifying Nx configuration, follow these principles:
Centralise in nx.json targetDefaults — Project-level project.json should only contain overrides. Define default inputs, outputs, dependsOn, and cache settings in targetDefaults keyed by executor name.
Use tokens, not hardcoded paths — Always use {projectRoot} and {workspaceRoot} instead of packages/my-package/.... Hardcoded paths break when packages are reorganised.
Scope inputs as tightly as possible, but don't forget build tools — Every file included in inputs that doesn't affect the target's output is a potential false cache invalidation. Use specific globs ({projectRoot}/src/**) rather than broad ones ({projectRoot}/**/*). But be careful not to go too narrow: include every file that affects the output — source files, local build/transform scripts (e.g., {workspaceRoot}/tools/compile-sass.js), relevant config files, and tool versions via externalDependencies.
Use dependentTasksOutputFiles for cross-package dependencies — Instead of including all upstream source in inputs, reference specific output file patterns:
"tsDeclarations": [{ "dependentTasksOutputFiles": "**/*.d.ts", "transitive": false }]
Use transitive: false unless you genuinely need the full dependency tree (e.g., pack).
Test cache behaviour after changes — Run the target, then run it again. The second run should be a cache hit. If not, investigate what changed between runs using NX_VERBOSE_LOGGING=true.
Consider the .nxignore file — Exclude generated files, vendored directories, patch infrastructure, and anything that shouldn't be in the project graph or trigger formatting.