With .tsbuildinfo, composite, and project references
Large TypeScript projects often suffer from slow builds because the compiler fully rechecks every file and recomputes every type on each run. This article shows in practice how the tsbuildinfo cache, the incremental and composite flags, and project references combined with tsc build noticeably reduce compile times in monorepos and large codebases, including concrete diagnosis and measurement methods for stubborn bottlenecks in everyday work.
Table of Contents
- 1. Why TypeScript builds get slow in large projects
- 2. The incremental flag: how the compiler avoids rechecking
- 3. The build info cache: the structure and contents of .tsbuildinfo
- 4. composite and project references: splitting codebases cleanly
- 5. The references array: dependencies between subprojects
- 6. tsc --build: orchestrating builds across multiple projects
- 7. Diagnosing slow builds with --extendedDiagnostics
- 8. Deep analysis with --generateTrace and the trace analyzer
- 9. Build strategies compared: full rebuild vs. incremental vs. project references
- 10. Summary
- 11. FAQ
1. Why TypeScript builds get slow in large projects
By default, the TypeScript compiler tsc rechecks every file in the project on every single run, regardless of whether anything has actually changed since the last build. For a small script that's not a problem, but in a grown codebase with several thousand files, complex generic types, and deep import chains, type checking quickly adds up to several minutes per run. This becomes especially painful in CI pipelines, where every build starts cold and the compiler begins from scratch with no context from previous runs.
The problem gets worse with the structure of many modern projects: monorepos with multiple packages, shared utility libraries, and frontend as well as backend code in a single repository mean that a single change in a central file could theoretically affect thousands of dependent files. tsc has no inherent notion of boundaries between logical modules, it just sees the files referenced in tsconfig.json as one large, connected compilation unit. This is exactly where incremental compilation and project references come in: they give the compiler the information needed to check and cache parts of the codebase independently of one another.
2. The incremental flag: how the compiler avoids rechecking
Setting "incremental": true in tsconfig.json tells tsc to save information about the project's state into a cache file after every successful build. On the next invocation, the compiler reads that file first and compares timestamps and file hashes against the current state of the file system. Only files that actually changed, plus files transitively affected by those changes, get type checked again. Every other result is taken straight from the cache, without the compiler needing to rebuild the syntax tree or re-resolve types.
How much speed you gain depends heavily on the blast radius of a change: changing an isolated utility function with few dependents pays off enormously, while changing a central type imported in a hundred files still triggers a broad recheck. In practice, incremental already pays off starting at medium-sized projects, because even with a larger blast radius the plain parsing and file system overhead disappears. It's important not to accidentally commit the generated cache file or reuse it across incompatible TypeScript versions, since stale caches are otherwise silently discarded and rebuilt from scratch.
3. The build info cache: the structure and contents of .tsbuildinfo
The .tsbuildinfo file produced by incremental is not an opaque binary blob, it is a JSON structure with clearly separated sections: a list of all source files, the resulting program options, a file version table with hashes, and a reference table that records which file depends on which other file. That dependency table is the actual core of the speed gain: when a file changes, tsc doesn't have to guess which other files might be affected, it can read the answer directly from the graph.
The storage location can be set explicitly via tsBuildInfoFile, which matters especially with multiple subprojects that each have their own tsconfig.json, in order to avoid collisions. In CI environments it's worth deliberately caching this directory between builds, for example through the caching mechanism of GitHub Actions or GitLab CI, since a warm build info cache can cut build time down to a fraction. If the file is missing on the first run, tsc automatically performs a full build and recreates the cache for all subsequent runs, with no manual intervention required.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"declaration": true,
"declarationMap": true,
"incremental": true,
"tsBuildInfoFile": "./node_modules/.cache/tsc/app.tsbuildinfo",
"outDir": "./dist"
},
"include": ["src/**/*.ts"]
}
4. composite and project references: splitting codebases cleanly
"composite": true turns on a stricter mode required for project references. Composite projects must declare all input files explicitly via include or files, every imported file must belong to the same project, and declaration files (.d.ts) are always generated so that other projects can consume the public API without re-parsing the source files themselves. Composite also implies incremental automatically, so the two mechanisms usually show up together in practice.
The real value of composite only shows once combined with multiple subprojects: instead of one enormous tsconfig.json with thousands of files, you get several smaller, independently compilable units, for example a core package, a utils package, and an app package, each with its own .tsbuildinfo. If only the app code changes, the compiler doesn't need to recheck core or utils at all, it simply loads their already generated declaration files straight from the output directory. That reduces not just build time but also the memory footprint of the compiler process substantially, since the entire program graph no longer needs to be held in memory at once.
5. The references array: dependencies between subprojects
The references array in tsconfig.json points to the directories of other composite projects that the current project depends on. tsc uses this information to build a dependency graph at the project level, separate from the file dependency graph inside a single project. Important detail: referenced projects must have composite set, and consuming code may only use the exported types visible in the declaration files, internal, non-exported implementation details stay invisible.
In practice this creates a natural architectural boundary: trying to import an internal helper module of another package that isn't part of its public API produces a clear compiler error instead of a silent but fragile dependency. Additionally, disableReferencedProjectLoad can be used to stop IDEs from automatically loading every referenced project, improving editor performance in very large monorepos, while tsc --build still respects all references correctly.
// tsconfig.json (repo root, solution style, no compilerOptions of its own)
{
"files": [],
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/utils" },
{ "path": "./packages/app" }
]
}
// packages/core/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"]
}
// packages/app/tsconfig.json
{
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
},
"references": [
{ "path": "../core" },
{ "path": "../utils" }
],
"include": ["src/**/*.ts"]
}
6. tsc --build: orchestrating builds across multiple projects
A regular tsc invocation largely ignores the references array and only compiles the current project. Only tsc --build, or tsc -b for short, fully understands the dependency structure: it works out the correct build order topologically, builds the projects without their own dependencies first, and then works its way up the graph, automatically skipping projects that are already up to date based on their .tsbuildinfo. The result is a build that behaves like a single incremental operation across the entire monorepo, even though technically many individual tsc processes are involved.
Useful flags for everyday use: --verbose shows which projects were rebuilt and which were skipped, --force forces a full rebuild of every referenced project regardless of cache state, and --clean removes all build outputs and cache files of the referenced projects. For watch mode development there is tsc --build --watch, which tracks changes across project boundaries and only rebuilds the subprojects actually affected, which dramatically shortens the feedback cycle especially in large frontend monorepos.
# Build the whole graph, respecting topological project order
tsc --build
# Show which projects were rebuilt and which were skipped as up to date
tsc --build --verbose
# Force a full rebuild of every referenced project, ignore .tsbuildinfo
tsc --build --force
# Remove all build outputs and .tsbuildinfo files for referenced projects
tsc --build --clean
# Watch mode across project boundaries, rebuild only affected projects
tsc --build --watch
7. Diagnosing slow builds with --extendedDiagnostics
--extendedDiagnostics gives a detailed breakdown of where the compiler is actually spending time: parsing time, binding time, type checking time, emit time, plus separate counters for the number of types checked, modules resolved, and symbols processed. This output is the first place to look when a build is unexpectedly slow, since it immediately shows whether the bottleneck lies in pure type checking, in module resolution, or in emitting output files.
A common pattern: a high "Types" count together with a high "Check time" points to complex, deeply nested generic types, which can often be defused by deliberately simplifying utility types. A high "Module resolution" time, on the other hand, often points to an unfavorably configured paths mapping or too many searched node_modules directories. Combined with --diagnostics, which gives a more compact summary, this makes it quick to decide whether an optimization should target tsconfig.json, the folder structure, or the types themselves.
tsc --noEmit --extendedDiagnostics
# Files: 842
# Lines of Library: 41230
# Lines of Definitions: 18904
# Lines of Source: 52117
# Types: 184220
# Instantiations: 612044
# Symbols: 298511
# Parse time: 1.42s
# Bind time: 0.61s
# Check time: 9.83s <- dominant cost, inspect types
# Emit time: 0.94s
# Total time: 12.80s
# Check time dominates: look for deeply nested generics or heavy
# conditional types before touching module resolution settings.
8. Deep analysis with --generateTrace and the trace analyzer
When --extendedDiagnostics only offers broad categories but the concrete root cause stays unclear, --generateTrace helps. The flag writes a Chrome tracing compatible file that can be opened directly in chrome://tracing or in the official @typescript/analyze-trace tool, showing every single type checking step resolved over time, including the exact file and position in the code where a given type got resolved.
The trace file is especially valuable for so-called "hot types", individual type definitions whose resolution costs a disproportionate amount of time across many different spots in the code, often caused by excessive use of recursive conditional types or deeply nested mapped types. The analyze-trace tool automatically lists such types sorted by total time and gives concrete starting points, instead of guessing at types in the code and testing them one by one. Combined with incremental compilation, this analysis is especially worthwhile for types that need to be resolved again on every build because they are imported centrally across many files.
# Write a Chrome-tracing compatible trace plus a type catalog
tsc --build --generateTrace trace-output
# Install and run the official trace analyzer on the output folder
npm install --no-save @typescript/analyze-trace
npx analyze-trace trace-output
# Example finding: a single mapped type resolved 4200 times
# across the codebase, costing 2.1s of the total check time
9. Build strategies compared: full rebuild vs. incremental vs. project references
The three approaches differ significantly in setup effort, maintainability, and actual speed gain. The table below summarizes when each strategy pays off and what the typical pitfalls of each look like.
| Strategy | Rebuild after 1 file change | Typical issue | Recommendation |
|---|---|---|---|
| tsc without incremental | Always full | Every tiny change costs full build time | Only for isolated scripts or release builds |
| tsc with incremental | Only affected files | .tsbuildinfo accidentally deleted or committed | Maintain .gitignore, persist the cache in CI |
| composite without tsc --build | Manual ordering required | Wrong build order produces stale .d.ts files | Always combine composite with tsc --build |
| tsc --build (references) | Only affected subprojects | Missing entry in the references array | Align project boundaries with real module boundaries |
| tsc --build --watch | Sub-second | High memory use with many watchers | For local development in large monorepos |
In practice, combining composite, references, and tsc --build is the standard for monorepos past a certain size, while a single incremental project is entirely sufficient for smaller, self-contained applications. The decisive mistake is rarely picking the wrong strategy, it's usually inconsistent execution, such as missing cache persistence in CI or project boundaries that don't match the actual module boundaries in the code.
Mironsoft
TypeScript tooling, build performance, and monorepo architecture for Magento and headless projects
Ready to speed up your TypeScript builds?
We analyze your tsc builds, set up incremental compilation and project references cleanly, and cut monorepos along real module boundaries, from the tsconfig strategy to CI cache configuration.
Build performance audit
extendedDiagnostics and trace analysis, prioritized by time saved
Monorepo restructuring
Structuring project references along clean package boundaries
CI cache setup
Anchoring .tsbuildinfo persistence and tsc --build in the pipeline
10. Summary
Incremental compilation in TypeScript solves a core problem of large codebases: without a cache, tsc rechecks everything on every single run, regardless of the actual scope of change. With "incremental": true and the resulting .tsbuildinfo file, the compiler only needs to recheck changed and transitively affected files. composite and the references array go a step further, splitting the codebase into independently compilable subprojects that tsc --build orchestrates in the correct order, automatically skipping projects that are already up to date.
Two complementary tools are available for diagnosis: --extendedDiagnostics quickly gives a rough overview of parsing, binding, checking, and emit times, while --generateTrace together with the analyze-trace tool drills down to individual types and file positions. Using both tools together lets you find the actual bottlenecks instead of guessing at tsconfig options, and lets you cut monorepos so that build times no longer grow linearly with project size but only with the actual scope of a given change.
Incremental Compilation in TypeScript - The Essentials at a Glance
incremental + tsBuildInfoFile
The cache file stores file hashes and the dependency graph, only changed files get rechecked.
composite + references
Split the codebase into independently compilable subprojects with their own .d.ts output.
tsc --build
Orchestrates the correct build order and automatically skips projects that are already up to date.
Diagnosis & measurement
--extendedDiagnostics for the overview, --generateTrace for deep analysis of individual types.