How does an in-process incremental compiler (like TypeScript's --incremental mode or a language server's recompilation) differ from build-system-level task caching, and where can each one still get correctness wrong?
answer
- opaque whole-task vs in-process fine-grained
- build-info file / watch mode
- structural typing breaks dependency tracking
- delete-and-rebuild as diagnostic
- CI avoids inherited incremental state
basics
~20 sA whole-task cache reuses a finished build's entire output if nothing changed; an incremental compiler stays running and only re-processes the specific files affected by an edit, keeping compiled state in memory or a save-file. Both skip redundant work, but the compiler's fine-grained version can go wrong if its internal dependency tracking misses a subtle edge case, like a change to a type that isn't textually referenced.
solid answer
~60 sTask-level caching (Bazel, Nx, Turborepo) treats a whole task — e.g. compiling a package — as one opaque unit: same input hash means reuse the whole output, different hash means redo the whole thing. An incremental compiler operates at finer granularity inside a single long-lived process: it keeps a persisted or in-memory model of the program (e.g. TypeScript's .tsbuildinfo, or a compiler daemon in watch mode), tracks which files/declarations changed, and recompiles only the transitively-affected subset. This is much faster for a single edit-save-recompile loop during local dev, but its correctness depends entirely on that internal dependency-tracking model being complete — if it misses that a change to an interface used only via structural typing (no explicit import) affects another file, it can produce a subtly wrong incremental result that a full recompile would have caught, which is why 'full rebuild fixes it' is a common workaround, and why CI typically does not rely on the incremental compiler's saved state, preferring a clean recompile or coarse-grained content-hash caching that's easier to reason about and safe by construction.
go deeper
Should understand that a 'watch mode' recompiling one file fast is a different, faster path than a full rebuild, without needing internal mechanics.
Should be able to name a concrete incremental compiler feature (e.g. TypeScript's incremental mode) and describe roughly what it skips.
Should clearly separate the two mechanisms' correctness guarantees and explain a realistic incremental-compiler staleness bug and its diagnostic.
Should articulate why CI deliberately doesn't inherit in-process incremental state and how the two layers compose in a real toolchain without being conflated.
## Two mechanisms that both go by 'incremental' There are two distinct mechanisms that both go by 'incremental' and are easy to conflate: whole-task caching at the build-system level, and fine-grained incremental compilation inside a single compiler process. They solve overlapping problems with very different mechanisms, and understanding both is necessary to reason about where a monorepo's rebuild speed is actually coming from. ## Task-level caching treats a task as a black box Task-level caching, as implemented by tools like `Bazel`, `Nx`, and `Turborepo`, treats each task (compiling a package, running a test file) as an opaque black box. It computes a content-addressed key from that task's declared inputs and either reuses a previously stored complete output or re-executes the entire task from a cold start. Nothing about the internals of the compiler matters to this layer — from its point of view, a compiler compiling forty files is indistinguishable from it compiling four hundred; it either replays the whole cached result or invokes the whole tool again. This granularity is coarse but simple and easy to reason about, because the correctness argument reduces to 'is the declared input set for this whole task exhaustive.' ## In-process incremental compilation works at a finer grain In-process incremental compilation works at a much finer grain, inside a single long-running compiler invocation. TypeScript's `incremental` flag (backed by a build-info file that persists a snapshot of the program's dependency graph and per-file semantic diagnostics) and its watch mode are the canonical example: rather than re-parsing and re-type-checking every file in a project on every keystroke-driven save, the compiler keeps (or reloads) a model of which files depend on which declarations, and when a file changes, it recomputes only that file and whatever the dependency graph says is transitively affected — not the whole project. A long-running watch process or a language-server process (the same engine that powers live editor type errors) amortizes this further by never fully restarting; it just applies incremental updates to its in-memory program model on every edit. The payoff is dramatic for the local edit-save loop: a full clean compile of a large TypeScript project might take 30+ seconds, but an incremental recompile of one changed file inside a warm watch process can be near-instant, because parsing, binding, and re-type-checking are the expensive steps and this mechanism skips them for everything the dependency model says is unaffected. ## Where the fine-grained mechanism gets correctness wrong The trade-off is that this speed comes from trusting an internal model of 'what depends on what' that's baked into the compiler's own logic, rather than from an external, structurally enforced input declaration like sandboxing provides for task-level caching. That internal model can be wrong in edge cases that are genuinely hard to track precisely: - **structural typing** — a type used only because its shape matches, with no explicit import; - **ambient/global type declarations**; - **decorators or metaprogramming** that alter behavior in ways the dependency tracker doesn't model; - **bugs in the incremental compiler itself**. When the model misses a real dependency, the result is the same class of failure as an under-specified cache key at the task level: a stale, subtly wrong recompilation that reports success but doesn't reflect a change that should have propagated. This is why 'delete the build-info cache and do a clean rebuild, and the bug goes away' is such a common and recognizable debugging step for TypeScript developers — it's a symptom of exactly this class of incremental-compiler staleness, not usually a bug in the developer's own code. ## Inner loop versus outer loop Because of this correctness gap, the two mechanisms are typically used in different places for different reasons. | Mechanism | Which loop it serves | |---|---| | Incremental, stateful compilation | optimized for the tight local inner loop — a developer saving a file every few seconds and wanting fast feedback, where an occasional false-negative (a missed error) is an acceptable, quickly-self-corrected cost against the alternative of a slow feedback loop killing productivity | | Whole-task content-addressed caching | optimized for the outer loop — CI, and cross-machine reuse — where correctness matters far more than saving the last few hundred milliseconds | For that outer loop, a full, from-scratch invocation of the compiler is preferred specifically because it doesn't carry forward any stateful in-process model that could be wrong; CI pipelines routinely disable or ignore incremental-mode state precisely to avoid inheriting a potentially-stale build-info file from a previous, unrelated commit. ## How the two layers compose A concrete pattern seen across many monorepo toolchains: `Nx` and `Turborepo` provide task-level caching for the whole build/typecheck command, while the underlying tool invoked by that command (`tsc`, `webpack` in dev mode, `jest` in watch mode) may itself use its own internal incremental mechanism during local development. These two layers compose — task caching decides whether to invoke the tool at all, and if it does invoke it, the tool's own incremental engine decides how much internal work it can skip — but they're solving the problem at different scopes with different correctness guarantees, and conflating them (assuming task-cache-level hermeticity extends to trusting the compiler's internal incremental state across machines) is a common source of 'why is CI's result different from my local build' confusion.
- Why do CI pipelines typically avoid reusing a build-info-style incremental state file across different commits?That file encodes an in-process compiler's internal belief about what's affected by what, computed against a specific previous program state; carrying it forward across an unrelated commit (or a different machine entirely) risks the compiler trusting a stale or non-matching model rather than reasoning freshly, which is the opposite of the safety guarantee CI wants. It's generally safer for CI to either do a clean compile or rely on the build system's own content-hash cache, which has an externally verifiable correctness argument.
- Is it accurate to say task-level caching makes in-process incremental compilation unnecessary?No — they solve different loops. Task-level caching only helps when nothing relevant changed at all (a full cache hit skips the tool entirely), but the moment even one file in a package changes, task caching gives you nothing, and it's the in-process incremental compiler that then makes recompiling that one changed package fast rather than starting the compiler cold each time.
- What's a concrete symptom that would make you suspect stale incremental-compiler state rather than a real bug in the code?A type error that logically should appear (or disappear) after a change doesn't show up until a full restart of the compiler/watch process or a deletion of its build-info cache — that's a strong signal the compiler's internal dependency model didn't correctly propagate the change, rather than the code itself being wrong.
Task-level caching is like deciding whether to re-cook an entire dish based on whether any ingredient changed; in-process incremental compilation is like a chef who keeps the pot on the stove and only re-seasons the part of the dish touched by the one ingredient that changed — faster, but relies on the chef correctly tracking which parts of the dish that ingredient actually touches.
saying these in an interview costs you the question
- Treats task-level caching and in-process incremental compilation as the same mechanism
- Doesn't know any concrete example of an incremental compiler (build-info file, watch mode, language server)
- Can't explain why CI often avoids trusting inherited incremental compiler state
- No mention of structural typing / hard-to-track dependencies as an incremental correctness risk