Home / Blog / TypeScript /TypeScript Build Performance: How to Speed Up Compilation and Type Checking in Large Codebases
TypeScript Build Performance: How to Speed Up Compilation and Type Checking in Large Codebases

TypeScript

October 28, 2026 - by Devico Team

TypeScript Build Performance: How to Speed Up Compilation and Type Checking in Large Codebases

A TypeScript build rarely becomes slow all at once. CI slips from ninety seconds to several minutes. Autocomplete starts pausing after each keystroke. Developers begin skipping the pre-commit hook because waiting for another full type check interrupts the work more than the hook helps.

Nothing is technically broken, but every change costs a little more time.

The first question is not which compiler flag to enable. It is where the time is going. TypeScript's diagnostics can separate parsing, binding, checking, and emitting, while its tracing tools can identify the files and type instantiations responsible for the delay. Once those numbers are visible, the available fixes range from a small tsconfig.json change to reorganizing an entire monorepo.

The baseline also changed in July 2026, when Microsoft released a native compiler reported to be roughly an order of magnitude faster than the JavaScript-based version. That speedup is significant, but it does not make configuration, dependency structure, or expensive type definitions irrelevant.

Why TypeScript gets slow at scale

A tsc run contains several distinct phases. TypeScript must:

  • Parse source files into syntax trees.

  • Bind declarations to symbols.

  • Check types across the program.

  • Emit JavaScript and declaration files when required.

Microsoft's performance guidance describes these as parse, bind, check, and emit time. In a large codebase, checking is usually the expensive part. It is where the compiler resolves generic parameters, evaluates conditional types, expands mapped types, and compares structural shapes across the dependency graph.

That distinction matters. Faster storage or a quicker bundler will not solve a build dominated by type comparison. Nor will parallelizing the emit stage make much difference if emit represents only a small fraction of the total.

The cost appears in several places. CI jobs take longer, slowing every pull request. The language service has more work to do when resolving types for editor features. Local hooks become intrusive enough that developers disable them. Even an extra two minutes can add up to hours each week when it is repeated across branches, commits, and CI runners.

Measure the build before changing it

Start with TypeScript's built-in diagnostics:

tsc --noEmit --extendedDiagnostics

The output includes parse, bind, check, and total time, as well as memory usage and counts for types, symbols, and instantiations. If Check time dominates, the problem is in type resolution rather than JavaScript output or disk I/O.

The shorter --diagnostics option is useful for a quick summary. --extendedDiagnostics provides the numbers needed for serious comparison.

Phase-level timing tells you what kind of work is slow, but not which part of the code causes it. For that, generate a trace:

tsc --noEmit --generateTrace ./trace-out

Available since TypeScript 4.1, --generateTrace writes trace.json and types.json. The trace can be opened in Chrome's chrome://tracing viewer, although a ranked analysis is usually easier to interpret:

npx @typescript/analyze-trace ./trace-out

The analyzer points to expensive files and type instantiations. That is often more useful than inspecting a large flame graph by eye.

Viget followed this process while investigating a slow application build. Diagnostics established the baseline; tracing then identified a single file responsible for more than 80 seconds of check time. The cause was an over-generic database query helper whose inference expanded against a schema containing about thirty tables.

Measurements need to remain comparable. Run the baseline and follow-up tests on the same machine, with the same command and a known cache state. Change one variable at a time. A CI run on different hardware is not reliable proof that a compiler change helped.

TypeScript 7.0 changes the performance baseline

For most of its history, the TypeScript compiler was written in TypeScript and executed through Node.js. Microsoft changed that architecture with TypeScript 7.0, a Go port of the compiler and language service developed under the codename Project Corsa.

Microsoft's published benchmarks show the VS Code codebase falling from 125.7 seconds to 10.6 seconds, an 11.9x improvement. Reported gains for other large projects ranged from approximately 7.7x to 11.9x. An earlier preview recorded an eightfold reduction in editor project-load time and roughly half the memory usage.

These are Microsoft's benchmarks, not independent audits, so the exact multiplier should not be treated as universal. Repository structure, type complexity, hardware, and compiler settings still affect the result.

Last verified: August 2026. TypeScript 7.0 tooling compatibility is changing quickly. Check the linked release notes before planning a migration.

The compiler upgrade is not yet a straightforward replacement for every project. TypeScript 6.0 is the last release of the JavaScript-based compiler and shares much of 7.0's stricter behavior, making it a useful migration step. TypeScript 7.0, however, shipped without a stable programmatic compiler API. Microsoft expects that API in TypeScript 7.1.

Until then, tools that depend on the previous compiler API cannot move fully to the native version. At the time of the 7.0 announcement, this included typescript-eslint and language tooling for Vue, Svelte, Astro, and Angular.

Microsoft provides @typescript/typescript6 as a compatibility package. It exposes a tsc6 binary that can be installed alongside the native tsc. A team can therefore use TypeScript 7.0 for CI type checking while keeping type-aware linting or framework tooling on the 6.0 API.

The upgrade may also expose configuration problems. TypeScript 7.0 converts several long-standing deprecations into errors and introduces stricter defaults, including:

  • strict enabled.

  • module set to esnext.

  • An empty types array instead of automatically loading dependency types.

  • An explicit rootDir.

Audit those settings before migration. Otherwise, the first native build may produce errors that the previous configuration never surfaced.

For a large codebase controlled end to end, the new compiler can substantially reduce CI and local type-checking time. Framework-heavy projects may need a temporary two-compiler setup until their tooling adopts the new API.

tsconfig.json settings with measurable impact

Several compiler options can reduce build time without changing the repository's architecture.

incremental: true stores information about a completed compilation in a .tsbuildinfo file. Later runs reuse that information and recheck only what changed. Configure tsBuildInfoFile to use a stable cache location, and persist that location between CI jobs if the CI platform supports caching.

For many projects, skipLibCheck: true produces the largest immediate improvement. It skips validation between declaration files in dependencies, which can account for a substantial portion of check time in repositories with large dependency trees.

It does not disable type checking for application code, nor does it stop TypeScript from checking how the application uses a library. The trade-off is narrower: incompatible declarations from two dependencies may go undetected.

isolatedModules: true ensures that every file can be transpiled independently. This is required when esbuild, swc, or Babel handles JavaScript output without building the full TypeScript program.

If the bundler already emits JavaScript, set noEmit: true for the compiler task. tsc can then focus on type checking instead of duplicating the bundler's work.

Setting
What it changes
Suitable use
Limitation

incremental + tsBuildInfoFile

Caches previous build information and rechecks changed inputs

Most projects, especially watch mode and CI with persistent caching

An unstable or discarded cache path provides little benefit

skipLibCheck

Skips checking dependency declaration files against one another

Most applications with substantial dependency trees

Can hide conflicts between library declarations

isolatedModules

Requires every file to be transpiled independently

Projects emitting JavaScript through esbuild, swc, or Babel

Rejects some const enum and type re-export patterns

noEmit

Runs type checking without writing JavaScript

Projects where a bundler already handles output

Declaration files still require a separate emit step

watchOptions exclusions

Prevents the watcher from scanning generated or otherwise irrelevant directories

Large repositories with substantial build output

Broad exclusions may omit legitimate source changes

Do not copy an entire "optimized" tsconfig.json without checking how it affects the project. Options such as target, lib, and moduleResolution influence runtime compatibility and module behavior, not just speed. They should match the deployment environment and bundler.

Project references for large repositories

Once a repository contains multiple packages, configuration flags can only go so far. The compiler needs boundaries that let it avoid rechecking unrelated code.

TypeScript project references divide a repository into separate compilation units. Each unit has its own tsconfig.json, uses composite: true, and lists its dependencies through a references array.

The build command then changes to:

tsc --build

or:

tsc -b

Build mode reads the reference graph, compiles packages in dependency order, and reuses their .d.ts and .tsbuildinfo output. If one package changes, TypeScript rebuilds that package and the affected downstream packages rather than checking the entire repository.

A typical setup uses a root tsconfig.json that emits nothing and lists package references. Each package should reference only the packages it actually imports.

Two structural problems can erase the benefit:

  1. A large shared-types package referenced by almost everything becomes a rebuild trigger for most of the repository. Frequently shared types should remain small and stable.

  2. Circular package references prevent TypeScript from establishing a valid build order. Those cycles need to be removed rather than cached around.

The reference graph is expensive to redesign after dozens of packages depend on it. Teams planning this work may examine how top TypeScript development companies structure package boundaries, but the final topology still has to reflect the repository's real dependency directions.

Project references also work alongside Nx, Turborepo, and similar task runners. They solve different layers of the problem:

  • Project references reduce the work performed inside a TypeScript build.

  • A task runner decides which builds should run and may reuse local or remote results.

A remote cache can prevent a known build from running again. It does not make an unnecessarily monolithic TypeScript project cheaper when the cache misses. Establish compiler-level boundaries first, then add task-level caching and parallelism.

Type patterns that make the checker work harder

Runtime profiling will not expose an expensive type definition. The delay exists inside the compiler, often in code that produces no JavaScript at all.

Tracing tends to reveal several recurring patterns.

Recursive and conditional types

Recursive conditional types can expand repeatedly at every use site. A type that walks a tuple or parses a string literal one character at a time may be concise in source code while requiring thousands of compiler instantiations.

Simplification is the most reliable fix. If a complex type is necessary, give it a name rather than repeating the expression inline. Named types give the compiler a better opportunity to cache and reuse the result.

A heavily generic API may also be more expensive than several explicit overloads. The overloads are longer on the page, but often easier for both the compiler and future maintainers to reason about.

Large unions and intersections

Conditional types can distribute across every member of a union. A generic operation over a union with dozens of members may therefore produce dozens of instantiations. Comparing two large unions can multiply that work further.

Microsoft recommends replacing very large closed unions with a base type and subtypes when the domain permits it. A small discriminant can still preserve useful narrowing without requiring every generic operation to expand over one sprawling union.

Deep intersections create a related problem. An interface that extends another interface can be built and cached as a single relationship. An intersection may require repeated structural comparison when used. This does not make intersections inherently wrong, but large, repeatedly evaluated intersections are worth investigating when tracing points to them.

Barrel files and oversized import graphs

A barrel file such as index.ts may re-export an entire directory so that consumers can use shorter import paths. That convenience can broaden the module graph loaded by the compiler and bundler, especially when barrels re-export other barrels or participate in circular dependencies.

Direct imports reduce those graph edges:

// Broad barrel import
import { X } from "../barrel"

// Direct import
import { X } from "../barrel/x"

In Viget's investigation, removing barrels and circular dependencies contributed to a reduction in total build time from about 6.2 minutes to 1.3 minutes, while memory usage fell by half. The largest individual improvement still came from the over-generic query helper, which is why tracing mattered more than applying one rule across the repository.

Use import type when a dependency is needed only in type position:

import type { Foo } from "./foo"

This makes the intent explicit and keeps the import out of the runtime emit graph.

Illustrative refactors

These examples show the shape of common fixes. They are not taken from a specific production repository.

Expensive pattern
Possible refactor
Why it may cost less

Recursive DeepValue<T> mapped across every nested property

Define the required configuration shape as a named interface

Avoids repeated recursive expansion when the domain has a known shape

A union containing dozens of variants

Use a small discriminant with an interface for each variant

Reduces distribution of generic operations across one large union

import { X } from "../barrel"

import { X } from "../barrel/x"

Loads a narrower portion of the import graph

A normal import used only as a type

import type { Foo } from "./foo"

Removes the dependency from the runtime emit graph

The objective is not to eliminate advanced TypeScript. It is to reserve expensive type-level computation for contracts that genuinely benefit from it.

Separate type checking from transpilation when necessary

Type checking and transpilation are separate operations.

tsc validates relationships between types. esbuild, swc, and Babel can remove TypeScript syntax and produce JavaScript, but they do not verify that the program is type-correct. esbuild's documentation states that it does not perform type checking and recommends running tsc --noEmit alongside it. swc follows the same model.

A common pipeline assigns each tool one job:

  • esbuild or swc handles the development server, watch mode, and JavaScript bundle.

  • tsc --noEmit runs as a background task, pre-push check, or separate CI job.

This keeps the local feedback loop fast without allowing type errors to merge unnoticed.

TypeScript 7.0 may make the split unnecessary for some teams. If the native compiler checks the repository in a few seconds, running the real checker in the development loop can be simpler than maintaining two toolchains.

The separation still makes sense for very large repositories and projects temporarily tied to tsc6 through framework or linting integrations. For a smaller project on the native compiler, the extra configuration may cost more than it saves.

Diagnose editor latency separately

A fast CI build does not guarantee a responsive editor.

CI usually runs tsc in batch mode. The editor relies on tsserver, which answers language-service requests for open files and the parts of the dependency graph they reach. The two workloads overlap, but they are not identical.

Autocomplete and navigation may remain slow because:

  • An expensive conditional or recursive type is resolved on demand.

  • The current project includes far more files than the editor needs.

  • Large third-party declaration files are loaded into scope.

  • Package references are missing or incorrectly configured.

VS Code can record these operations. Run TypeScript: Open TS Server Log, or configure typescript.tsserver.log, and inspect the request timings. Microsoft documents how to collect and read these logs.

Project references can help here as well. A smaller compilation unit gives the language service a narrower graph to load for the active file.

Some latency is the price of rich type information, particularly in codebases that use extensive inference. TypeScript 7.0's native language service should reduce that baseline substantially; Microsoft's preview reported project-load improvements of several times. Expensive types can still produce visible delays, even when the engine evaluating them is faster.

A repeatable TypeScript build optimization workflow

Build performance tends to deteriorate gradually. New dependencies widen the graph, packages accumulate references, and type abstractions become more ambitious. A one-time cleanup will not prevent that.

Use an ordered process:

  1. Record the baseline. Run tsc --noEmit --extendedDiagnostics and save check time, total time, instantiations, and memory usage.

  2. Trace the expensive code. Use --generateTrace and @typescript/analyze-trace to rank costly files and type instantiations.

  3. Apply configuration fixes. Check skipLibCheck, incremental, isolatedModules, emit settings, and watcher exclusions. Measure again.

  4. Refactor traced type bottlenecks. Name complex types, reduce oversized unions and intersections, remove unnecessary barrels, and use type-only imports.

  5. Review repository boundaries. For multi-package repositories, introduce or correct project references before relying on task-level caching.

  6. Reconsider the compiler setup. Evaluate whether TypeScript 7.0 works with the current framework and linting toolchain, and whether its performance makes earlier workarounds unnecessary.

The order is deliberate. Each stage either improves the numbers or narrows the remaining problem. Re-run the same measurement after every material change.

A quarterly review is a reasonable default, but teams should also define a CI threshold that triggers investigation earlier.

When build performance needs a dedicated owner

Compiler performance work often falls between teams. It touches application types, package boundaries, build tooling, and CI infrastructure, while competing with feature delivery for the same engineering time.

The problem is no longer incidental when:

  • No team owns the TypeScript build configuration.

  • Performance fixes repeatedly lose priority to roadmap work.

  • Every developer encounters the delay several times a day.

  • A TypeScript 7.0 migration affects framework tooling that still relies on the old compiler API.

  • The repository needs structural changes rather than another cache layer.

A focused engagement should produce measurable changes: a recorded baseline, traced bottlenecks, targeted type and configuration fixes, corrected project references, and a migration plan where required. It should also leave behind a process the internal team can repeat.

If the work would otherwise remain untouched while build time continues to rise, a company can hire TypeScript developers to handle the compiler migration and tooling without pulling the product team away from its roadmap.

The decision can be based on cost rather than frustration. Estimate how much engineering time the slow build consumes each week, then compare that figure with the effort required to fix it.

Frequently asked questions

Does upgrading to TypeScript 7.0 fix type-checking performance automatically?

It should reduce raw type-checking time substantially. Microsoft reports improvements of roughly 8–12x across its benchmark projects.

The upgrade does not simplify an expensive conditional type, remove circular package dependencies, or redesign a monolithic reference graph. It makes the compiler faster, but inefficient type work still exists. Some projects will also need to keep type-aware ESLint and framework tooling on tsc6 until the 7.1 compiler API is available.

Is skipLibCheck safe to use in production codebases?

Usually, yes. It skips checking declaration files from dependencies against one another. TypeScript still checks application code and validates how that code uses library APIs.

The main risk is a conflict between two libraries' declarations going unnoticed. For most applications, the reduction in build time is worth that limited trade-off.

Do swc or esbuild replace tsc?

No. They transpile TypeScript syntax into JavaScript but do not validate types. A pipeline that uses either tool still needs a type-checking step, usually:

tsc --noEmit

TypeScript 7.0 makes it practical for more teams to run that check directly in the development loop.

Why is the editor slow when CI builds are fast?

The workloads differ. CI runs a batch compiler build, while the editor uses tsserver to resolve types and answer requests for active files.

Expensive on-demand type resolution, oversized projects, or large third-party declaration files can make the editor slow even when a cached CI build is quick. TS Server logs show which language-service operations are taking the most time.

How often should TypeScript build performance be reviewed?

Quarterly is a sensible default, supplemented by an agreed CI threshold. Run the review sooner if type-check time rises sharply after a dependency update, package restructure, or new type-heavy feature.

The earlier a regression is traced, the easier it is to connect the slowdown to the change that caused it.

Stay in touch

Leave your email and we will inform you about all our news and updates

 

Up next