Problem
When creating and discarding multiple Project instances (e.g. in a pool, batch processing, or serverless handler), memory grows unboundedly because the underlying ts.LanguageService is never disposed.
TypeScript's ts.LanguageService has an explicit dispose() method that releases internal compiler caches (type resolution tables, symbol maps, parsed ASTs). These caches are not reclaimable by V8 garbage collection — they are held in internal data structures that persist even after all JS references to the Project are dropped.
ts-morph creates a ts.LanguageService via ts.createLanguageService() in LanguageService.ts (line 66), but:
- Never calls
dispose() on it
- Does not expose a
dispose() method on LanguageService or Project
- Provides no public API for users to clean up a
Project
The only workaround is reaching through the abstraction layer:
project.getLanguageService().compilerObject.dispose();
Reproduction
import { Project } from "ts-morph";
const fmt = (bytes) => `${Math.round(bytes / 1024 / 1024)}MB`;
async function run() {
const before = process.memoryUsage().rss;
for (let i = 0; i < 20; i++) {
const project = new Project({ useInMemoryFileSystem: true });
// Add some source files to trigger compiler cache population
for (let f = 0; f < 50; f++) {
project.createSourceFile(
`src/file${f}.ts`,
`export interface I${f} { id: string; name: string; value: number; }
export function process${f}(input: I${f}): string { return input.name; }`
);
}
// Trigger type checking to populate internal caches
project.getPreEmitDiagnostics();
// Uncomment to fix the leak:
// project.getLanguageService().compilerObject.dispose();
if (global.gc) global.gc();
const current = process.memoryUsage().rss;
console.log(`Iteration ${i + 1}: RSS = ${fmt(current)}, growth = ${fmt(current - before)}`);
}
if (global.gc) global.gc();
const after = process.memoryUsage().rss;
console.log(`\nTotal growth: ${fmt(after - before)}`);
}
run();
Run with node --expose-gc test.mjs.
Without dispose: ~832MB growth over 20 iterations (with forced GC).
With compilerObject.dispose(): ~1MB growth.
Expected Behavior
Project should have a dispose() method that cleans up the underlying ts.LanguageService and associated caches, so users can safely create and discard Project instances without leaking memory.
Suggested Fix
Add dispose() to LanguageService:
dispose() {
this.#compilerObject.dispose();
}
Add dispose() to Project:
dispose() {
this._context.languageService.dispose();
}
Environment
- ts-morph: latest (tested at commit 0dd1adc)
- Node.js: v22
- TypeScript: 5.x
Context
We discovered this in production running a Node.js service that pools Project instances for on-demand TypeScript typechecking. Pods were OOM-killing due to unbounded memory growth. The root cause was the language service's internal compiler caches never being released.
Problem
When creating and discarding multiple
Projectinstances (e.g. in a pool, batch processing, or serverless handler), memory grows unboundedly because the underlyingts.LanguageServiceis never disposed.TypeScript's
ts.LanguageServicehas an explicitdispose()method that releases internal compiler caches (type resolution tables, symbol maps, parsed ASTs). These caches are not reclaimable by V8 garbage collection — they are held in internal data structures that persist even after all JS references to theProjectare dropped.ts-morph creates a
ts.LanguageServiceviats.createLanguageService()inLanguageService.ts(line 66), but:dispose()on itdispose()method onLanguageServiceorProjectProjectThe only workaround is reaching through the abstraction layer:
Reproduction
Run with
node --expose-gc test.mjs.Without dispose: ~832MB growth over 20 iterations (with forced GC).
With
compilerObject.dispose(): ~1MB growth.Expected Behavior
Projectshould have adispose()method that cleans up the underlyingts.LanguageServiceand associated caches, so users can safely create and discardProjectinstances without leaking memory.Suggested Fix
Add
dispose()toLanguageService:Add
dispose()toProject:Environment
Context
We discovered this in production running a Node.js service that pools
Projectinstances for on-demand TypeScript typechecking. Pods were OOM-killing due to unbounded memory growth. The root cause was the language service's internal compiler caches never being released.