Missing dispose() on Project / LanguageService causes memory leak when creating multiple instances
- Dominant language
- TypeScript
- Stars
- 6.2k
- Forks
- 238
- Avg merge
- 2m
- Merged PRs (30d)
- 1
Description
## 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](https://github.com/microsoft/TypeScript/blob/main/src/services/services.ts) 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)](https://github.com/dsherret/ts-morph/blob/0dd1adc87da3a423f8796f77c2c41c2598170a48/packages/ts-morph/src/compiler/tools/LanguageService.ts#L66), but:
1. Never calls `dispose()` on it
2. Does not expose a `dispose()` method on `LanguageService` or `Project`
3. Provides no public API for users to clean up a `Project`
The only workaround is reaching through the abstraction layer:
```ts
project.getLanguageService().compilerObject.dispose();
```
## Reproduction
```js
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`:
```ts
dispose() {
this.#compilerObject.dispose();
}
```
Add `dispose()` to `Project`:
```ts
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.
Contributor guide
Research direction
Start in packages/ts-morph/src/compiler/tools/LanguageService.ts around the createLanguageService() call, then trace how Project accesses its language service. Expose disposal through LanguageService and Project so the underlying compiler service is released; verify that the public APIs support the reproduction without reaching through compilerObject.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- compilers, tooling
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100