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
tscfor 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.tsand 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
tscruns, 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.tsbuildinfofile with metadata so subsequent builds can be faster.--composite: required for projects that will be referenced by others; enforcesdeclarationand some other checks.--build(short:tsc -b): builds a project graph based onreferencesfields 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
- Audit your workspace: list packages, their TypeScript versions, and which output folders they already use.
- Add incremental flags to each package
tsconfig.json(but don’t yet wire references): set"incremental": trueand a reproducibleoutDir. - Enable composite on libraries you want other packages to consume (these will require
declarationenabled):"composite": trueand"declaration": true. - Introduce references one batch at a time: add
referencesfrom leaf packages upward, validate each step with CI. - Switch CI to
tsc -bafter the graph is stable; keep a fallback job that runs plaintscfor a few runs to validate parity. - Enable caching in CI for
.tsbuildinfoand 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.
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.,8192MB). - Use a workspace TypeScript version by setting
"typescript.tsdk"to keep editor and CLI behavior aligned. - Use
files.excludeandsearch.excludeto 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 --verboseto 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
outDiranddeclarationpolicies per package. - Decide which packages will be
composite(usually public libs). - Add incremental flags and commit baseline
.tsbuildinfoto cache locations (not into source control).
Common regressions and fixes
- Stale .tsbuildinfo or cache corruption: Delete
.tsbuildinfoand 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
outDirmapping and packagemain/typespoint to compiled artifacts, not sources. - Editor slowdown: Increase tsserver memory or scope project references via project-level
tsconfigandfilesexclusions.
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
Post a Comment