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:
- 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.
- A second program. A
filesoverride with its ownparserOptions.projectbuilds another program in the same process. That program loads every shared module and the generated client again. In one repo, ascripts/folder with its own tsconfig added 887 MB. - 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
- Peak RSS can’t compare two commits. With a raised
--max-old-space-size, V8 collects lazily, and the same commit measured 4.6 GB and 5.3 GB on two runs. Measure the heap left after a forced GC instead. --trace-gcundercounts with a big cap. Taking the largest size after a Mark-Compact misses the peak, because few full GCs run when the cap is high. One folder read 4 MB this way and 1 GB with the script above.tschits the same limit first.tsc --noEmitbuilds the same program. If CI runs type checking before lint, it runs out of memory there first, and the lint step never runs, so nobody sees lint’s memory grow.- Raising the heap only buys time. At about 2.7 MB per added file, 900 MB of headroom covers about 330 more files.
Alternatives
eslint --concurrency=2passes and runs faster, but each worker builds its own programs. Total RAM doubled to 7.3 GB in one test, which a small CI runner may not have.- Disabling type-aware rules saves the memory but loses the rules, which are the reason for the cost.