Skip to content

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

@teddysupercuts

Description

@teddysupercuts

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:

  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:

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions