Debugging Slow TypeScript Builds With generateTrace

Your editor takes four seconds to show a type error and tsc takes two minutes. There is a profiler for this and almost nobody uses it.

Share
Debugging Slow TypeScript Builds With generateTrace. Abstract javascript illustration in orange and dark grey on debugly.dev

The short answer

tsc --generateTrace ./trace

Produces trace.json and types.json. Open chrome://tracing or ui.perfetto.dev and load trace.json. The widest bars are your expensive files and types.

For a quick number first:

tsc --diagnostics
Files:                 1284
Lines:               412093
Nodes:              1893201
Identifiers:         642817
Symbols:             891203
Types:                84203
Instantiations:    28471003    <-- this one
Memory used:        1247382K
Check time:            18.4s

Instantiations in the tens of millions means a type is being expanded far more than it should. On a healthy medium project it is usually low single digit millions.

Tested on TypeScript 5.7.

Read the trace

Load trace.json into Perfetto and you get a flame graph of the compiler's work. The tracks that matter:

checkSourceFile per file. A single file taking 8 seconds when the rest take 40ms is your answer, and it is usually a file with a complex generic or a large union.

structuredTypeRelatedTo and checkExpression entries with long durations. These are assignability checks, and a long one means the compiler is comparing two large structural types.

findSourceFile taking a large share means module resolution, not type checking. That is a different problem with a different fix, usually too many path aliases or a lot of node_modules traversal.

Cross reference with types.json, which lists every type the compiler created with an id. When the trace names a type id, you can look it up there and find out what it actually is, which is frequently something you did not realise existed.

The usual causes

A recursive conditional type

The most common single cause of a slow project.

type Path<T> = T extends object
  ? { [K in keyof T]: K extends string ? K | `${K}.${Path<T[K]>}` : never }[keyof T]
  : never;

For a deeply nested config this expands enormously. Every usage re-instantiates it.

The fix is a depth limit, or replacing it with something concrete. I wrote about this in the excessively deep instantiation post, which is the extreme version of the same problem. Long before you hit the hard limit, these types make your editor sluggish.

Large unions compared against each other

type IconName = "arrow-up" | "arrow-down" | /* 400 more */;

function Icon({ name }: { name: IconName }) {}

Every call site checks a string literal against a 400 member union. That is fine. Comparing two large unions, or using one in a conditional type, is quadratic.

If you do not need exhaustiveness, a branded string is dramatically cheaper:

type IconName = string & { readonly __brand: "IconName" };

You lose autocomplete, which is a real cost. For genuinely large sets it is often the right trade.

Template literal type explosion

type Size = "sm" | "md" | "lg";
type Colour = "red" | "blue" | "green" | "yellow";
type Variant = "solid" | "outline" | "ghost";
type Class = `btn-${Size}-${Colour}-${Variant}`;   // 36 types

Fine here. Add a fourth dimension with ten members and you have 360. Add a fifth and you are in the thousands, each one a distinct type the compiler tracks.

These are seductive because they give beautiful autocomplete. Watch the instantiation count when you add one.

Deep generic nesting in hot paths

A utility type used in 400 places, each call re-instantiating a complex generic, is 400 instantiations of something expensive. The cost is invisible at any individual call site.

If a helper type appears everywhere, simplify it even at the cost of some precision.

Barrel files

// src/index.ts
export * from "./components";
export * from "./hooks";
export * from "./utils";

Importing one thing from a barrel pulls the whole graph into the compilation. In a large project this multiplies work substantially and it also breaks tree shaking in some bundler configurations.

Import from the specific module instead. This is one of the highest value changes in a large codebase and it is mechanical.

Isolating the file

If the trace is hard to read, bisect by file:

# time each file individually
for f in $(git ls-files '*.ts' '*.tsx'); do
  t0=$(date +%s%N)
  tsc --noEmit "$f" 2>/dev/null
  t1=$(date +%s%N)
  echo "$(( (t1-t0)/1000000 ))ms $f"
done | sort -rn | head -20

Crude, and it does not account for shared work, and it usually points at the right file in a couple of minutes.

Configuration wins

Several settings matter more than most people realise.

skipLibCheck: true. Skips type checking of .d.ts files. Most projects should have this on. Checking your dependencies' declaration files is largely wasted work, and one badly written declaration in a dependency can dominate your build with nothing you can do about it.

incremental: true. Writes a .tsbuildinfo file so subsequent builds only recheck what changed. Free, and make sure the file is in your CI cache.

Project references for a monorepo. Each package builds independently and downstream packages consume the emitted declarations rather than rechecking source:

{
  "references": [
    { "path": "../shared" },
    { "path": "../types" }
  ]
}

Then tsc --build. The setup cost is real and it is the single biggest win available for a large monorepo.

isolatedModules: true if you use a transpile-only bundler. It constrains you to constructs that can be compiled file by file, which is what esbuild and swc need anyway.

Reduce include scope. A tsconfig that includes test files, build scripts, and config files in the main build is checking a lot of code that does not need to be in that pass.

Separating type checking from building

For a fast dev loop, do not typecheck on every save in the bundler.

Most modern setups already do this: esbuild, swc, and Vite strip types without checking them, and you run tsc --noEmit separately. That gets you sub-second rebuilds and type errors on a slower cadence.

{
  "scripts": {
    "dev": "vite",
    "typecheck": "tsc --noEmit",
    "typecheck:watch": "tsc --noEmit --watch"
  }
}

Run typecheck:watch in a second terminal. You get fast rebuilds and continuous type feedback, which is better than either extreme.

In CI, tsc --noEmit runs as its own job in parallel with tests rather than blocking the build.

Watch it over time

The reason this problem creeps up on teams is that no single change makes the build slow. It degrades over months and everybody adapts.

Add the number to CI:

tsc --noEmit --diagnostics 2>&1 | grep "Instantiations" | tee -a metrics.txt

Fail the build if it grows past a threshold, or just graph it. A pull request that doubles instantiation count is worth a conversation, and without the number nobody notices.

This is the same argument as watching Server-Timing on a Shopify theme or tracking cache hit rates in CI. Slow degradation is invisible unless you measure it, and by the time it is obvious the cause is buried under six months of commits.

The judgement call

There is a real tension between type precision and compile speed, and the honest answer is that it is a trade rather than something you can optimise away.

My working position: elaborate type level programming is worth it in library code, where one complex type serves many call sites and one author pays the cost. In application code it usually is not, because the person paying is a colleague who now has to understand both the runtime logic and a type system program describing it, and their editor is slow while they do it.

When a build gets slow, the fix is frequently not a clever optimisation. It is deleting a type that was more clever than the problem required.