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...