Slash TypeScript Build Times in Monorepos: Practical Guide to Incremental Compilation & Caching

Hook: If your monorepo spends CI minutes (and developer patience) waiting on TypeScript builds, you’re not alone. Large workspaces with dozens or hundreds of packages can balloon TypeScript build time — but with the right combination of tsc project references, incremental compilation, and smart caching, you can cut build times by orders of magnitude. This guide gives a practical, low-risk migration path, copy-paste configs, CI cache recipes, and troubleshooting tips so you can speed up TypeScript compilation now.

Why TypeScript build times explode in monorepos — common causes and symptoms

Common root causes

When working with a monorepo, TypeScript build time often increases because of:

  • Naive independent builds: running tsc for every package without dependency awareness rebuilds shared code repeatedly.
  • Full program rebuilds: without incremental mode, any change can trigger a full re-typecheck of large program graphs.
  • Large declaration outputs: generating .d.ts and source maps for many packages is costly.
  • Poor CI caching: each run starts cold and recreates artifacts (and .tsbuildinfo) from scratch.
  • Circular dependencies & path alias churn that force broad invalidation.

Symptoms to watch for

  • Cold CI builds >10–20 minutes even for small changes.
  • Local edits take very long to compile in watch mode or in the editor.
  • CPU and memory spikes during tsc runs, and repeated repeated re-compiles for unrelated packages.

These are classic signs that your monorepo can benefit from incremental tsc monorepo strategies and better caching.

How tsc incremental, composite projects, and project references work

Key tsc switches: --incremental, --build, composite, declaration

TypeScript's incremental system revolves around a few flags:

  • --incremental: produces a .tsbuildinfo file with metadata so subsequent builds can be faster.
  • --composite: required for projects that will be referenced by others; enforces declaration and some other checks.
  • --build (short: tsc -b): builds a project graph based on references fields in tsconfig files and uses incremental data to skip unchanged work.

At a high level: enable composite on packages you want to expose, point dependents to them via references, and run tsc -b. TypeScript will compute the dependency graph and only rebuild affected projects, reusing .tsbuildinfo files to skip work.


What a project reference graph looks like

Example: packages A -> B -> C. Each package has a tsconfig.json with composite and the dependent package references the producer.

{
  "compilerOptions": {
    "composite": true,
    "outDir": "lib",
    "declaration": true
  }
}

And a dependent package:

{
  "compilerOptions": { ... },
  "references": [ { "path": "../package-a" } ]
}

Then from the repo root you can run:

tsc -b packages/* --verbose

Which will build packages in dependency order, using incremental metadata to skip unchanged work.

Step-by-step migration: convert packages to project references without breaking CI

Safe, staged migration plan

  1. Audit your workspace: list packages, their TypeScript versions, and which output folders they already use.
  2. Add incremental flags to each package tsconfig.json (but don’t yet wire references): set "incremental": true and a reproducible outDir.
  3. Enable composite on libraries you want other packages to consume (these will require declaration enabled): "composite": true and "declaration": true.
  4. Introduce references one batch at a time: add references from leaf packages upward, validate each step with CI.
  5. Switch CI to tsc -b after the graph is stable; keep a fallback job that runs plain tsc for a few runs to validate parity.
  6. Enable caching in CI for .tsbuildinfo and build outputs.

By incrementally adding references and keeping fallback validation, you can avoid hard-to-debug breakages in CI.

Automating references (sample script)

If you have many packages, generate references automatically. Example Node script (minimal):

/* scripts/generate-references.js */
const fs = require('fs');
const path = require('path');
const packages = require('../package.json').workspaces || ['packages/*'];
// Naive glob expansion (assumes packages/*)
const pkgs = fs.readdirSync(path.join(__dirname, '..', 'packages'));
pkgs.forEach(name => {
  const pkgPath = path.join('packages', name);
  const tsconfigPath = path.join(pkgPath, 'tsconfig.json');
  if (!fs.existsSync(tsconfigPath)) return;
  const pkg = JSON.parse(fs.readFileSync(path.join(pkgPath, 'package.json')));
  const deps = Object.keys(Object.assign({}, pkg.dependencies, pkg.devDependencies || {}));
  const references = deps
    .filter(d => d.startsWith('@myorg/'))
    .map(d => ({ path: path.relative(pkgPath, path.join('packages', d.replace('@myorg/', ''))) }));
  const cfg = JSON.parse(fs.readFileSync(tsconfigPath));
  cfg.references = references;
  fs.writeFileSync(tsconfigPath, JSON.stringify(cfg, null, 2));
});

This is a starting point — production scripts should detect cycles, respect custom folder layouts, and validate after change.

Caching strategies: local dev caches, CI caching, and remote caches

Local dev caches

Keep per-package outDir and .tsbuildinfo under versioned directories (or a cache folder) and don't delete them between runs. Use node_modules/.cache/tsbuild/ or .cache/tsbuild/ to persist local incremental state across clean installs.

CI caching (GitHub Actions example)

Cache both .tsbuildinfo files and compiled outputs. Key on lockfile and relevant tsconfigs so cache is invalidated when you change dependencies or compiler options.

# .github/workflows/build.yml (snippet)
- name: Restore build cache
  uses: actions/cache@v4
  with:
    path: |
      .cache/tsbuild
      packages/**/lib
    key: ${{ runner.os }}-tsbuild-${{ hashFiles('**/package-lock.json', '**/pnpm-lock.yaml', '**/tsconfig*.json') }}

- name: Build
  run: pnpm -w run build # or tsc -b

- name: Save build cache
  uses: actions/cache@v4
  with:
    path: |
      .cache/tsbuild
      packages/**/lib
    key: ${{ runner.os }}-tsbuild-${{ hashFiles('**/package-lock.json', '**/pnpm-lock.yaml', '**/tsconfig*.json') }}
    restore-keys: |
      ${{ runner.os }}-tsbuild-

Important: avoid overly broad cache keys. Include the tsconfig and lockfile hashes to prevent stale caches causing incorrect builds.

Remote cache options (Turborepo / Nx / self-hosted)

Task runners like Turborepo and Nx provide remote caching, storing build artifacts in their cloud or an S3-like remote. Benefits include stronger cache hits across branches and machines. Drawbacks: security and determinism concerns (must sign or control access) and extra setup.

Use remote caches when CI minutes or developer time costs justify it; otherwise a good per-run cache on CI (actions/cache or GitLab cache) plus incremental .tsbuildinfo will still give big wins.

Supporting image 2 — CI/CD pipeline caching visualization, UI mockup style, widescreen 1600x900. Dep

Quick wins: IDE, tsserver, and alternatives to heavy type-checking

IDE and tsserver tuning

  • Set "typescript.tsserver.maxTsServerMemory" in VS Code for large repos (e.g., 8192 MB).
  • Use a workspace TypeScript version by setting "typescript.tsdk" to keep editor and CLI behavior aligned.
  • Use files.exclude and search.exclude to prevent editor plugins from scanning built outputs.

Hot-path alternatives: transpile-only + parallel type-checking

If you want faster dev builds and are willing to accept a short type-checking delay, use a fast bundler (esbuild, swc, tsup) for transpilation and run tsc --noEmit --incremental in parallel (or in CI). This pattern dramatically improves developer feedback loops while preserving type safety.

// dev script example in package.json
{
  "scripts": {
    "dev": "concurrently \"esbuild src --outdir=lib --bundle --watch\" \"tsc --noEmit --watch\""
  }
}

Remember: core library packages that produce public types should remain composite and produce .d.ts to keep dependents stable.

Measuring impact: benchmarks, profiling build phases, and monitoring

Cold vs warm vs incremental benchmarks

Always measure before and after. Example benchmarking commands:

# Cold build (clean workspace)
rm -rf node_modules packages/*/lib .cache/tsbuild
time pnpm -w run build # or time tsc -b

# Warm build (rebuild after no changes)
time pnpm -w run build

# Incremental (after small change)
# edit a file in package-a
time tsc -b --verbose

Log and compare wall-clock times and compare CI minutes saved. For reproducible results, run on the same runner type or local machine and keep environment variables consistent.

Profiling build phases

  • Use tsc -b --verbose to see which projects are rebuilt and why.
  • Use process-level profilers (Linux: /usr/bin/time -v, Windows Perf) to find memory/CPU hotspots.
  • Collect CI build durations and track them over time with Grafana or a simple CSV to detect regressions.

Continuous monitoring tips

Record build times per PR and fail the PR if build time spikes beyond a threshold. This keeps team incentives aligned and prevents bitrot in build performance.

Real-world checklist and troubleshooting guide for regressions

Checklist: before you start

  • Pin TypeScript version across repo (avoid mixing tsc versions).
  • Ensure consistent outDir and declaration policies per package.
  • Decide which packages will be composite (usually public libs).
  • Add incremental flags and commit baseline .tsbuildinfo to cache locations (not into source control).

Common regressions and fixes

  • Stale .tsbuildinfo or cache corruption: Delete .tsbuildinfo and any cached artifacts, then rebuild. In CI, ensure cache keys change when relevant inputs change.
  • Circular references detected: Use dependency graph tooling (e.g., madge) to find cycles, and refactor to remove them or merge packages.
  • Wrong outputs in runtime: Ensure outDir mapping and package main/types point to compiled artifacts, not sources.
  • Editor slowdown: Increase tsserver memory or scope project references via project-level tsconfig and files exclusions.

For deeper debugging, inspect tsc -b --verbose output to see why a project is rebuilt (changed input, changed dependent, different compilerOptions, etc.).

Conclusion and next steps

Speeding up TypeScript builds in a monorepo is not a single flag flip — it’s a combination of architecture (tsc project references), tooling (incremental builds and fast transpilers), and operational practices (CI caching and monitoring). Start small: enable --incremental and concrete outDir for packages, generate references for a few packages, and add CI caching for .tsbuildinfo and build outputs. Measure cold/warm/incremental builds and iterate.

Want a reproducible migration playbook and scripts to auto-generate references, run benchmarks, and wire GitHub Actions cache keys? Try this approach in a feature branch and run the checklist above. If you hit a snag, consult the troubleshooting section and consider hybrid strategies (fast bundler for dev + tsc --noEmit for type-checking).

Ready to roll this out? Clone a minimal sample monorepo, follow the quickstart steps, and measure the wins. Tell your team: fewer CI minutes, faster PR feedback, and happier engineers.

Further reading and tooling: API design best practices, debugging techniques, microservices architecture.

Call to action: Try converting one small package to composite with references today, add caching to your next CI run, and measure the time saved. If you want, I can generate a PR template or a small script tailored to your repo layout—tell me your package structure and I’ll draft a starter script.

Comments

Popular posts from this blog

Graph Visualization using MSAGL with Examples

Practical Example To Visualize Entities In Live Application Using MSAGL

How to Count the Number of Times a Statement Executes Using Visual Studio Breakpoint Conditions