Find what fills the heap when type-aware ESLint runs out of memory

node: 24 eslint: 10 typescript-eslint: 8
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

Measure the heap each folder leaves behind, then give each TypeScript program its own ESLint process.

// eslint-heap.mjs: lint groups in order in one process, print the heap each one leaves behind.
// usage (from the repo root): node --expose-gc --max-old-space-size=16000 eslint-heap.mjs src/one-file.ts src/a src/b,src/c scripts
import { createRequire } from "node:module";
const require = createRequire(process.cwd() + "/package.json");
const { ESLint } = require("eslint");

const eslint = new ESLint({ cwd: process.cwd() });
const heap = () => {
  global.gc();
  global.gc();
  return process.memoryUsage().heapUsed / 1048576;
};

let prev = heap();
for (const group of process.argv.slice(2)) {
  const results = await eslint.lintFiles(group.split(","));
  const now = heap();
  const added = now - prev;
  console.log(
    `${group.padEnd(32)} files=${String(results.length).padStart(5)} +${added.toFixed(0).padStart(5)}MB ` +
      `${(added / results.length).toFixed(1)}MB/file  total=${now.toFixed(0)}MB`,
  );
  prev = now;
}

Start with a single file: its line is the cost of building the program before any rule runs. Every later line is that folder’s own cost.

Why This Happens

typescript-eslint keeps every program it builds, and every type its rules asked about, in memory until the run ends. Type-aware rules ask about most expressions, so a full lint run holds about what a full tsc --noEmit holds, for each program, at the same time.

The heap grows with three things:

  1. The program itself. A large generated client (Prisma, GraphQL codegen, an OpenAPI SDK) is part of every program that imports it, even when you ignore it in ESLint. A 270k-line generated Prisma client made the base program cost 1.26 GB before linting started.
  2. A second program. A files override with its own parserOptions.project builds another program in the same process. That program loads every shared module and the generated client again. In one repo, a scripts/ folder with its own tsconfig added 887 MB.
  3. Per-file type work. Expect 1 to 1.5 MB per file. Files that return deeply nested ORM types through framework generics cost more: Fastify route files with Prisma results cost 4.7 MB per file, and each route added about 1 MB.

The default heap is about 4 GB on a desktop and about 2 GB on a GitHub-hosted runner. A growing codebase crosses it gradually, so git bisect for a culprit commit usually finds nothing.

The Fix

Run each program in its own process, so each one gets the whole heap:

{
  "scripts": {
    "lint": "eslint . --ignore-pattern scripts --max-warnings 0 && npm run lint:scripts",
    "lint:scripts": "eslint scripts --max-warnings 0"
  }
}

In CI, make each one its own step, and set the heap for the steps that need it:

- name: Lint
  run: npm run lint
  env:
    NODE_OPTIONS: --max-old-space-size=4096

When one program alone outgrows the heap, split it by folder in the same way. Every process pays the base program cost again, so split only as far as you need.

Gotchas

Alternatives

eslint typescript-eslint memory heap oom projectService performance

Was this helpful?

Related entries

typescript-eslint --fix can break Prisma JSON column writesMake a switch fail to compile when a union case is missingMake array and record lookups admit they can be undefinedCheck an object against a type without losing its literal types