Why type checking slows down as an Nx workspace grows

This guide is for backend and platform engineers running large Nx workspaces where type checking time is already a measured problem: slow CI typecheck targets, editors that lag after a branch switch, and machines that run out of memory partway through a check.

Most Nx workspaces start with path aliases. A shared base tsconfig maps import specifiers like a library's scope name to that library's source entry point, and every project extends it. The setup is simple, but it has a cost that grows as the repo does. When tsc checks a project, it follows each aliased import into the dependency's source files, then into that dependency's imports, and so on. Every project's type check parses and checks the source of its entire dependency closure. If ten applications depend on the same core library, that library is type checked ten times, once inside each consumer's program.

The result is that type checking cost tracks closure size, not change size. A one-line edit to a leaf application still pays for re-parsing everything beneath it. As shared libraries pile up at the bottom of the graph, every check gets slower, even for projects nobody touched.

TypeScript project references address this. Each project becomes its own compilation unit that declares its dependencies explicitly and emits declaration files. A consumer reads those .d.ts outputs instead of re-checking the dependency's source. In build mode, tsc can then skip projects whose inputs have not changed, so the work tracks what you edited instead of everything beneath it.

What it is, who should use it, and how it works

What is it?

Project references are a TypeScript compiler feature, not an Nx feature. A tsconfig can list other tsconfig files in a references array. Each referenced project is marked composite: true, which turns it into a self-contained build unit with a fixed file list and declaration output. Using them in Nx means giving every Nx project its own composite tsconfig, wiring references to match the Nx project graph, and running type checks with tsc --build instead of a single large tsc --noEmit program per project. Nx still orchestrates and caches the tasks. TypeScript handles build order and incremental skipping inside each run.

Who should use it?

Project references suit teams that meet most of these conditions:

  • The workspace has many projects and a deep or wide dependency graph, with shared libraries that many apps consume.
  • Type checking time or memory has been measured and is a real bottleneck in CI or in editors.
  • Most pull requests touch a small number of projects, so narrowing work to changed projects matters.
  • Someone can own the build configuration and keep references in sync, ideally with tooling and a CI check.

Teams with small workspaces, few shared libraries, or no measured type checking problem are usually better served by path aliases and Nx caching. The trade-offs section below covers this in more detail.

How does it work?

At a high level:

  1. Each project's composite tsconfig emits .d.ts files and a .tsbuildinfo file describing the last compilation.
  2. tsc --build reads the references graph and processes projects in dependency order.
  3. For each project, it compares current inputs with the recorded .tsbuildinfo and skips projects that are up to date.
  4. When a dependent project is checked, imports into a referenced project resolve to that project's emitted declarations instead of its source, so shared libraries are not re-checked inside every consumer.
  5. Nx wraps this in a cacheable typecheck target, so unchanged projects can also be restored from the Nx cache without running tsc at all.

The next section covers each step in detail.

How project references and tsc build mode work

Composite projects and their outputs

A project becomes eligible for references when its tsconfig sets composite: true. This adds constraints that make the project a predictable build unit. Declaration emit is required, every source file must be matched by include or files, and rootDir defaults to the tsconfig's directory. With those guarantees, a downstream project can rely on a fixed set of .d.ts files describing the project's public types.

Each composite project also writes a .tsbuildinfo file. It records the files that went into the last compilation, their hashes, the dependency graph between them, and enough diagnostic state to let the next run reuse prior work. This makes incremental type checking possible across separate tsc invocations, not only within a long-running watch process.

What tsc --build does

Running tsc --build (or -b) on a project first resolves its references into a graph, then processes it in topological order, building dependencies before dependents. For each project, tsc compares its inputs against the recorded outputs and build info. If nothing relevant changed, it skips the project. When a dependent is checked, imports into a referenced project resolve to that project's emitted declarations, not its .ts sources. A large library is therefore represented by a few compact declaration files.

Contrast with path aliases

The default Nx setup maps library names to source directories through paths in a shared base tsconfig. Each consumer then includes every transitively imported source file in its own program. If five apps depend on the same core library, that library's source is parsed and checked five times. No boundary exists where the work can stop or where results can be reused.

PROJECT REFERENCES  (tsc --build, dependencies first)

 +--------------------+     +--------------------+     +------------------+
 | libs/core          |     | libs/ui            |     | apps/web         |
 | composite: true    |---->| refs: core         |---->| refs: ui, core   |
 | src/*.ts           |     | checks vs core.d.ts|     | checks vs *.d.ts |
 +---------+----------+     +---------+----------+     +------------------+
           |                          |
           v                          v
 +--------------------+     +--------------------+
 | dist/*.d.ts        |     | dist/*.d.ts        |
 | core.tsbuildinfo   |     | ui.tsbuildinfo     |
 +--------------------+     +--------------------+
   inputs unchanged vs tsbuildinfo  ---->  project skipped on next run

PATH ALIASES  (paths in shared base tsconfig ---> library source dirs)

 +---------+  +---------+  +---------+  +---------+  +---------+
 | app-a   |  | app-b   |  | app-c   |  | app-d   |  | app-e   |
 +----+----+  +----+----+  +----+----+  +----+----+  +----+----+
      |            |            |            |            |
      +------------+------------+------------+------------+
                                |  each program includes the source
                                v
                    +-----------------------+
                    | libs/core/src/**/*.ts |
                    | parsed + checked x5   |
                    | no reusable boundary  |
                    +-----------------------+

With project references, tsc --build builds composite projects in dependency order, skips projects whose inputs match their .tsbuildinfo, and checks dependents against emitted .d.ts files; with path aliases, every consuming app re-parses and re-checks the shared library's source.

Structuring tsconfig files per Nx project

Breaking that repetition starts at the root. The workspace-level tsconfig.json becomes a solution file. It sets files to an empty array and lists references to each project. It compiles nothing itself. Shared compiler options move to a base config, commonly tsconfig.base.json, which every project extends. The root file then only describes the graph.

Each project gets its own small graph. The project's tsconfig.json is another solution file that references tsconfig.lib.json and tsconfig.spec.json. The lib config enables composite, which requires declaration, and sets a dedicated outDir and tsBuildInfoFile. It includes only source files and excludes *.spec.ts and test helpers. That keeps test types, Jest or Vitest globals, and fixtures out of the declarations that downstream projects consume.

The spec config references the lib config instead of re-including its sources, so tests are checked against the library's emitted types. An app config follows the same pattern. Nothing references an app, but it still benefits from being a node in the build graph.

Next, change how imports resolve. Path aliases in the base config point directly at another project's source. That lets a project pull in files it never declared as a reference. Under composite this surfaces as errors about files outside the project's file list. When it doesn't error, it quietly reintroduces the duplicate checking you were removing.

Linking packages through your package manager's workspaces avoids this. Each library declares its entry points in package.json exports, with a types condition. Imports then resolve the way they will at runtime, provided moduleResolution is node16, nodenext, or bundler. Every cross-project import also corresponds to a declared dependency. That gives you an explicit dependency list that references can be checked against or generated from.

// tsconfig.json (workspace root): solution file, compiles nothing itself
{ "files": [], "references": [{ "path": "./libs/utils" }, { "path": "./apps/api" }] }

// libs/utils/tsconfig.json: per-project solution file
{ "files": [], "references": [{ "path": "./tsconfig.lib.json" }, { "path": "./tsconfig.spec.json" }] }

// libs/utils/tsconfig.lib.json: emits the declarations downstream projects consume
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "rootDir": "src",
    "outDir": "dist",
    "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
  },
  "include": ["src/**/*.ts"],
  "exclude": ["src/**/*.spec.ts", "src/test-utils/**"] // keep test code out of .d.ts
}

// libs/utils/tsconfig.spec.json: tests checked against the lib's emitted types
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "outDir": "out-tsc/spec",
    "types": ["vitest/globals", "node"] // test globals stay in this config only
  },
  "include": ["src/**/*.spec.ts", "src/test-utils/**/*.ts"],
  "references": [{ "path": "./tsconfig.lib.json" }]
}

// libs/utils/package.json: consumers resolve built types via exports, not path aliases
{
  "name": "@acme/utils",
  "private": true,
  "exports": {
    ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
  }
}
// apps/api/package.json then declares "@acme/utils": "workspace:*" (pnpm/Yarn)
// and apps/api/tsconfig.app.json lists { "path": "../../libs/utils/tsconfig.lib.json" } in references

A root solution tsconfig, a per-project solution file that splits composite lib and spec configs, and a package.json exports map so cross-project imports resolve to emitted declarations through workspace linking instead of path aliases.

Keeping references in sync and making type checking cacheable

Workspace linking gives each project a declared dependency list in package.json that references can be checked against. Treat that list as the source of truth and generate references from it. If someone adds an import and updates package.json but not tsconfig.json, tsc --build may not build the dependency first. It then reports missing modules or types that look correct in the source. The reverse case, a stale reference to a dropped dependency, quietly adds build-order constraints and slows every run. Nx provides a TypeScript sync generator that rewrites references from the project graph. Run nx sync locally and nx sync:check in CI, so drift fails a pipeline step instead of a reviewer's attention.

A typecheck target Nx can cache

Declare the typecheck target's inputs as the project's source files, its tsconfig files, and the outputs of its dependencies. Declare its outputs as both the declaration directory and the .tsbuildinfo file. They have to be cached together. tsc decides what to skip by reading tsbuildinfo. If a cache hit restores declarations without the matching tsbuildinfo, tsc rechecks work that is already done. If it restores a tsbuildinfo whose outputs are missing or from a different run, tsc can treat the project as up to date and skip emitting. Downstream projects then fail against declarations that aren't there. Keep the tsbuildinfo inside a declared output path, not next to the tsconfig.

Editor behavior

With references, the TypeScript language service normally sends go-to-definition to the referenced project's source. If you turn that redirect off for performance, enable declarationMap so navigation still reaches source rather than .d.ts files. Errors that the command line doesn't report usually come from stale declaration output. Rebuild the affected project, then restart the TypeScript server.

// libs/orders/project.json (Nx parses comments in project.json)
// Assumes tsconfig.lib.json sets "outDir": "out-tsc", "declaration": true,
// "declarationMap": true and "tsBuildInfoFile": "out-tsc/tsconfig.lib.tsbuildinfo"
{
  "name": "orders",
  "targets": {
    "typecheck": {
      "executor": "nx:run-commands",
      "options": {
        "command": "tsc --build tsconfig.lib.json",
        "cwd": "{projectRoot}"
      },
      // Dependencies must emit their declarations first
      "dependsOn": ["^typecheck"],
      "inputs": [
        "{projectRoot}/src/**/*.ts",
        "{projectRoot}/tsconfig*.json",
        "{workspaceRoot}/tsconfig.base.json",
        // A change to an upstream .d.ts invalidates this project's cache entry
        { "dependentTasksOutputFiles": "**/*.d.ts", "transitive": true }
      ],
      // One output directory holds both the .d.ts files and the tsbuildinfo,
      // so a cache hit always restores them as a matching pair
      "outputs": ["{projectRoot}/out-tsc"],
      "cache": true
    }
  }
}

A cacheable Nx typecheck target. Its inputs include upstream declaration files. Its single output directory holds both the emitted .d.ts files and the .tsbuildinfo, so Nx always restores them together.

Step-by-step incremental migration

Start by removing cycles. TypeScript refuses to build references that form a loop, so any cycle in the Nx project graph will block the migration once it reaches those projects. Run nx graph, find the circular edges, and break each one by moving the shared code into a new lower-level library or by inverting the dependency through an interface. Do this before touching any tsconfig, while the change is still a plain refactor that is easy to review.

Next, convert the leaf libraries, meaning the ones with no internal dependencies. Set composite: true in each one's library tsconfig and run tsc --build against it. Composite mode is stricter than a normal compile. Every source file must match the project's include or files list, so a stray file that was previously compiled only because something imported it will now cause an error. Composite mode also requires declaration output, which exposes exported types that cannot be named without reaching into another package's internals. Fix these errors here, while each project's surface is small.

Then work up the graph one layer at a time. Add references entries that match each project's real Nx dependencies. Run tsc --build --verbose to confirm the build order and the up-to-date checks, and tsc --build --dry to see what would rebuild without writing any output. Switch the CI target to build mode only after a full workspace run passes and a second, unchanged run reports nothing to rebuild.

Checklist

  • No cycles in nx graph
  • Leaf libraries composite and building cleanly
  • Every file matched by include
  • References mirror Nx dependencies, layer by layer
  • --verbose order correct; --dry shows no work on a clean rerun
  • CI switched last
#!/usr/bin/env bash
set -euo pipefail

npx nx graph

npx tsc --build libs/util-strings/tsconfig.lib.json --verbose

npx tsc --build libs/feature-auth/tsconfig.lib.json --verbose

npx tsc --build tsconfig.json --verbose

if npx tsc --build tsconfig.json --dry --verbose | grep -q "would build"; then
  echo "Unchanged rerun still wants to rebuild; not ready for CI" >&2
  exit 1
fi

echo "Build mode is stable; safe to switch the CI target"

A verification script for one migration layer. It builds a leaf library and then its dependent with tsc --build --verbose, then uses --dry to confirm an unchanged rerun schedules no work before CI is switched.

Trade-offs, when to avoid it, and alternatives

Project references are not free, and the costs land mostly during adoption.

  • Configuration overhead. Every project needs composite, declaration output, and a references list that must track the Nx graph. Without sync tooling or a CI check, these drift.
  • Hidden errors surface. Under path aliases, a library's source was type-checked with the consumer's compiler options. Once each project is checked with its own options, latent errors appear. Budget time to fix them before the migration counts as finished.
  • No circular dependencies. tsc -b rejects reference cycles outright. A cycle Nx tolerated as a lint warning becomes a hard blocker that you must remove by extracting shared code.
  • Stale output. If .d.ts files or .tsbuildinfo survive a deleted or renamed source file, consumers can type-check against outdated declarations and pass when they should fail.
  • Slower cold builds. Declaration emit adds work on a clean checkout. The payoff comes only on warm, incremental runs.

Skip project references for small workspaces, or when nobody has measured type-check time as a real bottleneck. The overhead buys nothing there.

Approach Warm speed Setup cost Typical failure mode
Path aliases + tsc --noEmit Rechecks all reachable source each run Minimal Slows steadily as the graph grows
Nx caching alone Fast on cache hits Low Any cache miss pays the full check cost
Project references + tsc -b Rechecks only changed projects and their dependents High Stale output, drifting references, cycle errors

Measuring impact on cost and engineering time

Benchmark in your own repository, because the gains depend on graph shape and on how often changes land in leaf libraries versus shared foundations. Run each scenario several times and keep the median. Use --skip-nx-cache for the cache-off runs so you isolate what tsc -b contributes by itself.

Scenario Nx cache off Nx cache on
Cold (clean checkout, no .tsbuildinfo) Baseline full check Full check, which populates the cache
Warm (no changes) tsc -b up-to-date checks only Cache replay
One leaf library edited That project plus its dependents Misses only on affected projects

The leaf-change row matters most. It is the typical pull request, and it shows whether references actually narrow the work.

Illustrative estimate (assumed numbers, not a benchmark)

  • CI minutes saved per month = (T_before − T_after) × CI runs per month
  • Developer hours saved per month = (t_before − t_after) × local checks per developer per day × developers × workdays ÷ 60

Assumed inputs:

  • CI type check on a leaf change drops from 12 to 4 minutes across 200 runs per month. That saves (12 − 4) × 200 = 1,600 CI minutes.
  • Local checks drop by 1.5 minutes each, with 20 developers running 6 checks per day over 20 workdays. That saves 1.5 × 6 × 20 × 20 ÷ 60 = 60 hours.

Subtract the ongoing cost from these savings. That cost includes reviewing reference changes, debugging stale output, and onboarding engineers to the build model. Assume it is 4 hours per month. If the net result stays clearly positive after you substitute your measured values, adoption pays off. If the measured leaf-change gain is small, Nx caching alone is probably enough.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nx typescript project references, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.