A TypeScript monorepo builds with `tsc -b`, yet every CI run recompiles all projects from scratch even though only one package changed. How do you diagnose and fix that?
answer
- ask the compiler why, do not guess
- state must survive between runs
- build info without outputs is useless
- checkouts rewrite modification times
- version and options invalidate the record
basics
~20 sRun tsc -b --verbose to see why each project is considered out of date. Typically CI restores neither the .tsbuildinfo files nor the emitted outputs, or a fresh checkout resets file timestamps, or a changed compiler version or config invalidates the recorded build state.
solid answer
~50 sStart with `tsc -b --verbose`, which prints a reason per project — usually naming the output that is older than an input, or reporting that no build info was found. From there the causes are a short list. A fresh CI checkout has no `.tsbuildinfo` and no `dist`, so the first build is necessarily complete; incrementality only exists if you cache both the build-info files and the emitted outputs, since the up-to-date check compares them against the inputs. Checkouts also rewrite file mtimes, so restored outputs can look older than freshly written sources. And build info is only valid for the compiler version and options it recorded — a `tsc` upgrade, or an edit to the shared base config every package extends, legitimately invalidates the whole graph. Point `tsBuildInfoFile` inside `outDir` so one cache path covers both, and key the cache on the TypeScript version and the config files.
code
bash · 8 lines# Why is each project rebuilding?
npx tsc -b --verbose
# Show the plan without spending the build time
npx tsc -b --dry
# Release path: never trust partial incremental state
npx tsc -b --clean && npx tsc -b --forcego deeper
Know that incremental builds depend on files left behind by the previous build, and that a clean CI machine has none of them, so the first build is always a full one.
Explain the up-to-date check — build info plus input/output timestamps — and name what invalidates it: missing outputs, changed compiler version, changed options. Reach for tsc -b --verbose to see the reason per project.
Walk the diagnosis end to end: read --verbose, classify the cause, fix the cache scope and ordering, key the cache on compiler version and config hash, and decide where forced clean builds still belong in the pipeline.
Own the policy: which branches get incremental builds versus forced clean ones, what the cache is keyed on, and when a broad rebuild is really a signal that the reference graph concentrates too much of the public type surface in one package.
## Establish what the compiler thinks before changing anything The first move is not to change the cache configuration; it is to make the compiler explain itself. ```bash npx tsc -b --verbose ``` Build mode then prints a line per project, of the form "Project 'packages/api' is out of date because output file 'dist/index.js' does not exist" or "... because output 'dist/index.js' is older than input 'src/index.ts'". That single output usually collapses the search space: *no output exists* is a caching problem, *output older than input* is a timestamp problem, and *build info version mismatch* is an invalidation problem. `--dry` shows the same plan without doing the work, which is useful when a run takes twenty minutes. ## Cause 1: nothing was carried over between runs Incremental building is state-based, and CI is stateless by default. A clean container clones the repo and starts with no `.tsbuildinfo` and no `dist` directories. Under those conditions rebuilding everything is not a bug — it is the only correct behaviour. The fix is to cache the build state, and the crucial detail is that **build info alone is not enough**. The up-to-date check compares emitted outputs against inputs, so restoring `.tsbuildinfo` while discarding `dist` leaves the compiler with a record of work whose products are gone; it rebuilds anyway. Cache both. The simplest way to guarantee that is to keep them in one place — set `tsBuildInfoFile` to a path inside the project's `outDir`, so a single cached directory per package covers the entire state: ```json { "compilerOptions": { "composite": true, "outDir": "./dist", "tsBuildInfoFile": "./dist/.tsbuildinfo" } } ``` ## Cause 2: timestamps, not contents The up-to-date check is timestamp-driven. A git checkout writes every working-tree file at checkout time, so all sources look brand new. If your cache restore then writes outputs that carry older mtimes — or restores them before the checkout — every project reads as out of date even though nothing meaningful changed. This one is diagnosed straight from `--verbose`: the reason line names a specific output as older than a specific input, and comparing the two files' mtimes on the runner confirms it in seconds. The fix is ordering and preservation: restore outputs after the checkout, and use a cache mechanism that preserves or sensibly assigns modification times. ## Cause 3: the recorded state no longer applies A `.tsbuildinfo` file records the compiler version and the options it was produced with. Change either and it stops being usable, so the graph rebuilds — correctly. Two events do this routinely: - **Upgrading TypeScript.** A renovate bot bumping `typescript` invalidates every package's build info on that run. This is expected; it is only a problem if your cache key does not include the version, in which case you also keep restoring stale entries that are then discarded. - **Touching the shared base tsconfig.** In a repo where forty packages extend one base config, a single edit there changes the effective options of all forty. This is the tradeoff of centralised config, and it is why base configs should change deliberately rather than casually. Cache keys should therefore include the TypeScript version and a hash of the config files, so a restore either matches the current settings or does not happen at all. ## Cause 4: the graph itself defeats skipping If the above check out and CI still rebuilds broadly on small changes, look at the shape of the reference graph. Build mode stops the cascade when a rebuilt project emits **identical declarations**; a change confined to a function body typically does that. But a package whose public types everything depends on will fan out on almost any signature edit, and a graph where every leaf references a large shared `types` package concentrates all invalidation in one node. Narrowing what is exported, splitting a god-package, or moving volatile types out of a widely referenced one buys real build time — but this is a design change, not a configuration change, so measure first with `--verbose` and confirm the fan-out is genuinely where the time goes. ## What to do when you cannot trust the state There is a legitimate opposite move. For release builds, correctness beats speed: run `tsc -b --force`, or `tsc -b --clean` followed by a normal build, and accept the full cost. Many teams run incremental builds on pull requests and forced clean builds on the release branch, which keeps the fast path fast without ever shipping artefacts produced from a half-restored cache. ## Reporting the diagnosis A good answer names the evidence before the fix: `--verbose` says why, and the reason points at exactly one of *state not restored*, *timestamps misleading*, *build info invalidated*, or *graph fans out*. Jumping to "add a cache step" without that evidence is how teams end up caching a directory that was never the problem.
- Why is caching `.tsbuildinfo` without caching the emitted output directories ineffective?The up-to-date check compares emitted outputs against their inputs. With `dist` missing, the outputs simply do not exist, so every project is out of date regardless of what build info says. Keeping `tsBuildInfoFile` inside `outDir` makes the two impossible to cache separately by accident.
- After bumping the `typescript` dependency, the whole graph rebuilds. Bug or expected?Expected. Build info records the compiler version it was produced with, and a different version may emit or check differently, so the recorded state is discarded. The right response is to include the TypeScript version in the cache key so you neither restore nor retain entries that will be thrown away.
- When would you deliberately run `tsc -b --force` in a pipeline rather than fixing the caching?On release or publish builds, where a wrong artefact costs far more than a slow build. A common split is incremental builds on pull requests for feedback speed and a forced clean build on the release branch, so nothing ever ships from partially restored incremental state.
saying these in an interview costs you the question
- Adds a cache step without reading --verbose first
- Caches build info but not the emitted outputs
- Blames tsc rather than the stateless CI environment
- Thinks the up-to-date check hashes file contents
- Treats a rebuild after a compiler upgrade as a defect