dsherret / dsherret/ts-morph

Missing dispose() on Project / LanguageService causes memory leak when creating multiple instances

Open
#1,666 0 comments 0 reactions 0 assignees View on GitHub
bug
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.