Why does tsgo use so much memory?

zeldapoem1 pts0 comments

Why does tsgo use so much memory?

5/27/2026<br>Why does tsgo use so much memory?

{pubDate.toString()}} -->

If you run tsgo on decently sized Typescript project, it’s not uncommon to see<br>it using gigabytes of memory.

Why is that?

The short answer is:

when multi-threading, tsgo makes a type checker per thread

each type checker has its own state (types, symbols, etc.)

this state is not shared as synchronizing it between threads is costly

so each type checker often allocates duplicate, redundant memory

in addition, allocated types are never freed

It’s not uncommon for Typescript projects to have:

several thousand Typescript files

libraries like Zod, tRPC, Drizzle which result in many, many type instantiations

recursive generic types which product a lot of transient types which are never freed

When running tsgo on a large Typescript project, these type creation patterns<br>compound and result in a lot of duplicated or unused memory.

Let’s dig deeper.

Heap analysisλ

Let’s first get a breakdown of the heap so we can see what’s taking up so much<br>memory.

I’ll run tsgo on a large nextjs project with Zod, tRPC, Drizzle, all the good<br>stuff that makes the typechecker do work. Including node_modules, it’s about 7k<br>.ts files.

We can use Go’s runtime/pprof package to capture peak heap snapshots and the<br>pprof tool to tell us which functions allocated the most memory with the<br>-inuse_space flag.

If we categorize them by AST, typechecker etc. we see this:

Total live heap: 1471.9 MB<br>pprof writer self-overhead: 75.2 MB<br>real live data: 1321.5 MB

MB pct Family<br>──────────────────────────────────────────────────────────────────────────────<br>594.72 45.0% AST arenas (parser-allocated)<br>399.12 30.2% Checker (type/signature computation)<br>121.79 9.2% LinkStore (per-node/per-symbol caches)<br>63.38 4.8% OS / syscall / file I/O<br>62.58 4.7% Binder (symbol/flow declarations)<br>22.33 1.7% Parser (intern maps, etc.)<br>20.24 1.5% pkg: collections<br>15.54 1.2% Checker arenas<br>13.46 1.0% AST utilities<br>6.58 0.5% Compiler / module resolution<br>1.10 0.1% pkg: core<br>0.70 0.1% pkg: packagejson

What sticks out at first glance is 45% of memory (600MB) is allocated for AST<br>nodes. It sounds like a lot, but it’s actually expected for the bulk of the<br>memory allocated by a compiler to be taken up by AST nodes.

AST nodes also typically need to live for the duration of the compiler’s<br>execution, so there’s nothing we can really do here. A lot of files means a<br>lot of AST nodes!

I’m more interested in the memory allocated by the typechecker (the Checker<br>struct in the source).

What happens if we run tsgo with --singleThreaded?

Total live heap: 797.4 MB<br>pprof writer self-overhead: 3.6 MB<br>real live data: 790.2 MB

MB pct Family<br>──────────────────────────────────────────────────────────────────────────────<br>522.95 66.2% AST arenas (parser-allocated)<br>63.37 8.0% OS / syscall / file I/O<br>62.63 7.9% Binder (symbol/flow declarations)<br>51.93 6.6% Checker (type/signature computation)<br>23.01 2.9% LinkStore (per-node/per-symbol caches)<br>22.51 2.8% Parser (intern maps, etc.)<br>16.78 2.1% AST utilities<br>16.15 2.0% pkg: collections<br>10.21 1.3% Compiler / module resolution<br>0.58 0.1% pkg: packagejson<br>0.10 0.0% pkg: core<br>0.01 0.0% ** unclassified **

The typechecker takes up only ~50MB instead of ~400MB! This strongly suggests to<br>me that there is some overhead with multi-threading here.

Let’s look at the typechecker deeper.

The type checkerλ

The way tsgo multi-threads typechecking is by creating a pool of Checker<br>for each thread:

// internal/compiler/checkerpool.go

func newCheckerPoolWithTracing(program *Program, tr *tracing.Tracing) *checkerPool {<br>checkerCount := 4<br>if program.SingleThreaded() {<br>checkerCount = 1<br>} else if c := program.Options().Checkers; c != nil {<br>checkerCount = *c

checkerCount = max(min(checkerCount, len(program.files), 256), 1)

pool := &checkerPool{<br>program: program,<br>checkers: make([]*checker.Checker, checkerCount),<br>locks: make([]*sync.Mutex, checkerCount),<br>tracing: tr,

return pool

When a Checker is created, it is given the entire Typescript program AST<br>and all its files:

// internal/checker/checker.go

func NewChecker(program Program, tracer *Tracer) (*Checker, *sync.Mutex) {<br>program.BindSourceFiles()

c := &Checker{}<br>c.id = nextCheckerID.Add(1)<br>c.tracer = tracer<br>c.program = program<br>c.compilerOptions = program.Options()<br>c.files = program.SourceFiles()<br>c.fileIndexMap = createFileIndexMap(c.files)

// ... more code

During typechecking and emitting diagnostics for a file, each file gets assigned<br>to the next available Checker.

Each Checker has it’s own state for type-checking (which we’ll see in more<br>detail later). Here’s an example of duplicated work:

File a.ts goes to Checker 1, it creates a bunch of types.

File b.ts imports some type from a.ts and goes to Checker 2.

Checker 2 has its own separate state, so it needs to recompute and re-allocate data for a.ts.

From the pprof run I noticed the top allocating Checker functions...

checker program memory type tsgo allocated

Related Articles