Repository navigation
First incremental rebuild after a clean build is up to 75× slower than a full check #64464
Description
🔎 Search Terms
incremental noEmit slow first edit, tsbuildinfo signature, declaration emit getAlternativeContainingModules getAliasForSymbolInContainer, isSymbolAccessible slow, affected files signature, typescript 7 incremental slower than full check
🕗 Version & Regression Information
- This is the behavior in every version I tried: 5.4.5, 6.0.3, 7.0.2 and 7.1.0-dev.20260925.1. It is not the 5.5 builder change from Incremental builder re-emits entire transitive import closure on a whitespace-only edit (regression in 5.5: exportedModulesMap removal + text version hashes stored as shape signatures) #63664.
- In TypeScript 7 the ratio to a full check is much worse (about 60–75× instead of 8–10×), because a full check got much faster, while this path gained little and runs almost on one core.
⏯ Playground Link
No response
💻 Code
The problem needs thousands of files and build state across runs, so here is a generator instead of a snippet. hub.ts stands for a widely imported constants module. Each leaf exports a value whose inferred type names an interface that the leaf never imports.
// generate.js — node generate.js <dir> [leaves=6000] [types=3000]
const fs = require('fs');
const path = require('path');
const dir = path.resolve(process.argv[2] || 'repro');
const LEAVES = Number(process.argv[3] || 6000);
const TYPES = Number(process.argv[4] || 3000);
fs.rmSync(path.join(dir, 'src'), {recursive: true, force: true});
fs.mkdirSync(path.join(dir, 'src/types'), {recursive: true});
fs.mkdirSync(path.join(dir, 'src/leaves'), {recursive: true});
const write = (file, text) => fs.writeFileSync(path.join(dir, 'src', file), text);
write('hub.ts', 'export const HUB_PREFIX = "hub";\nexport const HUB_LIMIT = 10;\n');
for (let i = 0; i < TYPES; i++) {
write(`types/t${i}.ts`,
`export interface Model${i} { id: string; value${i}: number; }\n` +
`export function make${i}(id: string): Model${i} { return {id, value${i}: ${i}}; }\n`);
}
write('types/index.ts', Array.from({length: TYPES}, (_, i) => `export {make${i}} from './t${i}';\n`).join(''));
for (let i = 0; i < LEAVES; i++) {
const a = i % TYPES;
const b = (i * 7 + 3) % TYPES;
write(`leaves/l${i}.ts`,
`import {HUB_PREFIX} from '../hub';\n` +
`import {make${a}, make${b}} from '../types';\n` +
`export const leaf${i} = {first: make${a}(HUB_PREFIX), second: make${b}('${i}')};\n`);
}
write('index.ts', Array.from({length: LEAVES}, (_, i) => `export * from './leaves/l${i}';\n`).join(''));
fs.writeFileSync(path.join(dir, 'tsconfig.json'), JSON.stringify({
compilerOptions: {
target: 'es2022', module: 'esnext', moduleResolution: 'bundler', strict: true,
noEmit: true, incremental: true, skipLibCheck: true,
},
include: ['src'],
}, null, 2));node generate.js repro && cd repro
rm -f tsconfig.tsbuildinfo
tsc -p . --extendedDiagnostics --incremental false # full check
tsc -p . --extendedDiagnostics # clean incremental build
echo '// comment-only edit' >> src/hub.ts
tsc -p . --extendedDiagnostics # first edit: slow
echo '// another comment-only edit' >> src/hub.ts
tsc -p . --extendedDiagnostics # second edit: fast againWall times on a 32-core Linux machine (6000 leaves, 3000 types):
| 5.4.5 | 6.0.3 | 7.0.2 | 7.1.0-dev.20260925.1 | |
|---|---|---|---|---|
full check (--incremental false) |
3.0 s | 3.2 s | 0.57 s | 0.56 s |
| clean incremental build | 3.6 s | 3.3 s | 0.83 s | 0.82 s |
| no-op rebuild | 2.2 s | 2.0 s | 0.59 s | 0.61 s |
first comment-only edit to hub.ts |
31.7 s | 24.5 s | 34.3 s | 41.8 s |
| second comment-only edit | 2.5 s | 2.3 s | 0.73 s | 0.74 s |
for comparison: --incremental false --noEmit false --declaration --emitDeclarationOnly --outDir out |
23.9 s | 8.2 s | 11.4 s |
The cost grows faster than the program: with 7.0.2, the first edit takes 4.1 s at 1500 leaves, 8.5 s at 3000 and 34.3 s at 6000.
🙁 Actual behavior
After a clean incremental build, the first edit to a module that many files import takes much longer than checking the whole program from scratch, even when the edit is a comment. In 7.0.2 the time is reported as Check time, and in the nightly as Emit time.
A CPU profile of that run (--pprofDir) shows where it goes. The builder emits declarations for the edited file's importers to compute their shape signatures, even with --noEmit. Serializing the inferred types asks whether each named symbol is accessible, and getAlternativeContainingModules then scans every source file for a module that re-exports the symbol:
flat flat% cum cum%
2.54s 6.66% 30.28s 79.45% checker.(*Checker).getAlternativeContainingModules
1.93s 5.06% 24.93s 65.42% checker.(*Checker).getAliasForSymbolInContainer
2.71s 7.11% 2.71s 7.11% ast.IsNonLocalAlias
0 0% 32.78s 86.01% declarations.(*DeclarationTransformer).ensureType
Duration: 34.20s, Total samples = 38.11s (111.42%)
The run uses about one core: 111% CPU, against about 700% for a full check. The same declaration emit run on its own (--emitDeclarationOnly) takes 8.2 s, so running it for signatures costs about 4× as much.
After that first edit the .tsbuildinfo holds real declaration signatures, and later edits are fast again. That includes edits that change the hub's exports, and edits to other widely imported modules.
🙂 Expected behavior
The first incremental rebuild after a comment-only edit should cost about as much as a full check, not 8–75× more. At least, computing signatures should not scan the whole program once per symbol, and it should run in parallel the way checking does.
Additional information about the issue
This is how it shows up in a real application with 22,154 files (a React + Express monorepo, TypeScript 7.0.2, tsc -p src/ui --noEmit --incremental). After a clean build, the first comment-only edit to a 112-line constants module takes 291 s. That module is re-exported through a shared barrel that about 900 files import. A full check of the same program takes 21 s and the no-op rebuild 3.5 s. The next edit takes 4.7 s. On the warm .tsbuildinfo, an edit that adds an export to that module takes 24 s.
The profile of that run matches the synthetic one: 58% in getAlternativeContainingModules, 136% CPU over 303 s.
flat flat% cum cum%
57.08s 13.80% 57.08s 13.80% ast.IsNonLocalAlias
15.43s 3.73% 242.09s 58.54% checker.(*Checker).getAlternativeContainingModules
14.86s 3.59% 217.74s 52.65% checker.(*Checker).getAliasForSymbolInContainer
0 0% 228.96s 55.37% declarations.(*DeclarationTransformer).ensureType
Duration: 302.67s, Total samples = 413.53s (136.63%)
Separately, tsc --watch --noEmit on that project took 437 s for its first check and peaked at 13 GB; I haven't profiled that run. For now we run our dev-server type checks with incremental off. A five-minute wait after every clean build costs more than faster rebuilds save afterwards.
#63830 reports the same hotspot in declaration emit for tsc -b on a private codebase and is waiting on a shareable repro. The generator above reproduces it without private code. I can attach the raw pprof files.
Activity
[-]`--incremental --noEmit`: first edit after a clean build spends minutes in declaration emit (`getAlternativeContainingModules`), up to 75× a full check[/-][+]First incremental rebuild after a clean build is up to 75× slower than a full check[/+]Index re-exporting modules for declaration emit