tsc Build Performance: Project References, Incremental Builds, and skipLibCheck
Concept
tsc does two jobs at once by default: whole-program type checking and emitting JavaScript. Type checking is the expensive half — it has to load and analyze every .ts file reachable from the program's roots, including the .d.ts files of every dependency, and re-derive types across module boundaries. On a large codebase this cost grows faster than file count, because checking isn't purely local: a change to a widely-imported type can force re-analysis of everything that consumes it. Three mechanisms in tsc exist specifically to keep this bounded as a codebase grows: incremental compilation, project references, and skipLibCheck.
Incremental compilation ("incremental": true) makes tsc write a .tsbuildinfo file recording each file's dependency graph and a hash of its last-checked state. On the next build, tsc diffs against that file and only re-checks/re-emits files whose inputs actually changed, instead of the whole program. tsBuildInfoFile lets you point that cache file somewhere explicit (useful when multiple tsconfig.json variants share a source tree and would otherwise clobber each other's cache).
Project references ("references": [...] plus "composite": true on each referenced project) go a level further: they split one big program into multiple smaller TypeScript projects with explicit dependency edges between them, each independently incremental. A composite project must emit .d.ts declaration files ("declaration": true is implied), because a downstream project consumes its declarations, not its source — tsc never re-type-checks an upstream project's internals when building a downstream one, only reads its already-computed public types. tsc --build (or -b) walks the reference graph and rebuilds only the projects whose inputs (or whose upstream references' outputs) changed, in dependency order — the TypeScript-native analogue of a monorepo task-runner's affected-package detection, but enforced at the type level rather than just the file-watch level.
skipLibCheck skips type-checking the bodies of .d.ts files entirely (your own code's usage of them is still checked normally) — it doesn't verify that every dependency's shipped declaration files are internally consistent with each other, which matters because large dependency trees frequently contain declaration files that technically conflict (two libraries each shipping incompatible ambient globals, or overlapping @types versions) without that conflict ever mattering to code that only consumes the public surface.
Tradeoffs
| Strategy | Benefit | Cost |
|---|---|---|
Plain tsc (no incremental, no references) |
Simplest config, correct by construction, nothing to misconfigure | Every build re-checks the whole program; fine for small projects, becomes the slowest option as the codebase grows |
incremental: true |
Free win for the common "edit a few files, rebuild" loop — no structural changes needed | Cache can go stale/corrupt across branch switches or CI cache misses, occasionally needing a full rebuild to recover; doesn't parallelize anything, still one program |
Project references (composite + tsc -b) |
Rebuilds only affected projects; enables true parallelism across independent leaves of the reference graph; each project's boundary is enforced by the compiler, not just convention | Real upfront restructuring cost — every project needs composite: true, explicit references, and emitted declarations; a wrongly-drawn project boundary (a false shared dependency) can force more rebuilding than expected |
Transpile-only (esbuild/swc) + separate tsc --noEmit type-check |
Per-file transpilation is dramatically faster than tsc's whole-program emit, since it does no cross-file type analysis at all; good for fast dev-server rebuilds |
Type errors aren't caught by the fast path at all — needs a separate, still-slow tsc --noEmit pass (in CI, or a background watch) as the actual safety net; isolatedModules: true is required to guarantee every file is independently transpilable (rules out const enums and some re-export patterns that need whole-program context) |
These aren't mutually exclusive — a common production setup runs esbuild/swc for the fast dev loop and CI's fast smoke build, and tsc -b with project references and skipLibCheck for the authoritative type-check step, getting most of both benefits at once.
When to use / when not to
- Turn on
"incremental": trueby default for any project past trivial size — it's close to free and has no real downside beyond a.tsbuildinfofile to.gitignore. - Reach for project references specifically in a monorepo with genuine internal package boundaries (a shared
corepackage consumed by several apps) — the win comes from those boundaries mapping to real, infrequently-changing modules; splitting a single cohesive app into references for its own sake usually just adds config overhead without a real rebuild-avoidance payoff. - Pair a transpile-only dev/build tool with
tsc --noEmitin CI once build speed genuinely blocks iteration (multi-second rebuild-on-save) — don't reach for this on a project wheretscalone is already fast enough, since it adds a second toolchain and a second place configuration can drift. - Leave
skipLibCheckon by default in application code (not library code) — it's the standard recommendation precisely because real declaration-file conflicts in the dependency tree are common and almost never actionable from the application side.
Common pitfall
Enabling project references without giving each referenced project a real, narrow public surface — if composite projects still import each other's internal (non-exported) files via deep relative paths instead of going through each package's declared entry point, tsc -b's incremental rebuild boundary breaks down: a change deep inside project A forces a full re-check of B even though B only used A's public API, because the reference graph can't tell the difference between "B depends on A's public surface" and "B depends on A's implementation details." The fix is enforcing the same package-boundary discipline exports fields enforce at the npm level, internally, between reference-graph projects.
Engineering Lens
Build performance work on a TypeScript codebase tends to get reached for reactively, once rebuilds are already painful, rather than designed in from the start — but project references specifically punish that ordering, because retrofitting clean project boundaries onto a codebase that grew without them is real refactoring work, not a config flag. The judgment call worth making early, before the codebase is large, is where the natural internal package boundaries are (even if they're not yet separate npm packages) — because those same boundaries are what composite/references need to pay off, and drawing them under time pressure later tends to produce boundaries that match convenience rather than actual dependency structure.
Sources
- TypeScript: TSConfig Option — tsBuildInfoFile
- TypeScript Project References at Scale — jsmanifest, Medium