You're running ESLint on your large codebase, and suddenly your terminal fills with worker thread errors. The linting process crashes halfway through, leaving you with incomplete results and a frustrating "ENOMEM: not enough memory" error. This happens specifically when ESLint tries to spawn multiple worker threads to speed up the linting process.
Here's what the error looks like:
$ eslint . --ext .js,.jsx,.ts,.tsx
Error: spawn ENOMEM
at ChildProcess.spawn (node:internal/child_process:413:11)
at Object.spawn (node:child_process:757:9)
at Linter.verifyAndFix (node_modules/eslint/lib/linter/linter.js:1543:29)
Oops! Something went wrong! :(
ESLint: 9.34.0
Fatal error: Worker thread exited with code 134
Step 1: Understanding the Error
ESLint v9 introduced enhanced multithread support to speed up linting in large projects. Instead of processing files sequentially, ESLint spawns multiple worker threads that lint files in parallel. Each worker thread requires its own memory allocation, configuration loading, and parser initialization.
The ENOMEM error occurs when your system runs out of available memory to spawn additional worker threads. This typically happens in projects with:
- More than 5,000 files
- Complex TypeScript configurations
- Multiple ESLint plugins with heavy AST transformations
- CI/CD environments with memory constraints
- Node.js processes with default heap limits
Let me show you a typical project structure that triggers this error:
my-large-project/
├── src/
│ ├── components/ (2,500 files)
│ ├── services/ (1,200 files)
│ ├── utils/ (800 files)
│ └── pages/ (1,500 files)
├── eslint.config.js
└── package.json
Your eslint.config.js might look like this:
// eslint.config.js
import js from '@eslint/js';
import typescript from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import importPlugin from 'eslint-plugin-import';
export default [
js.configs.recommended,
{
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2024,
sourceType: 'module',
project: './tsconfig.json', // This loads the entire TS project
},
},
plugins: {
'@typescript-eslint': typescript,
'react': react,
'react-hooks': reactHooks,
'import': importPlugin,
},
rules: {
'@typescript-eslint/no-unused-vars': 'error',
'react/prop-types': 'off',
'import/order': ['error', { 'newlines-between': 'always' }],
},
},
];
When you run eslint . with this configuration, ESLint automatically detects your CPU cores and tries to spawn one worker thread per core. On an 8-core machine, that means 8 separate Node.js processes, each loading the TypeScript parser, project configuration, and all plugins.
Step 2: Identifying the Cause
The memory issue stems from three main factors working against you:
First, TypeScript parsing with project references. When your parser options include project: './tsconfig.json', each worker thread loads the entire TypeScript project into memory. This isn't just your source files but also all type definitions, node_modules types, and the TypeScript compiler API itself. For a project with 6,000 files, this can easily consume 500-800MB per worker thread.
Second, plugin initialization overhead. Each plugin maintains its own rule state and AST visitors. When you have five or six plugins active, every worker thread duplicates this initialization. The import plugin, for instance, builds a dependency graph that can take 100-200MB in large monorepos.
Third, Node.js default heap limits. By default, Node.js allocates around 1.4GB of heap memory on 64-bit systems. When ESLint spawns 8 worker threads, and each needs 600MB, you're looking at 4.8GB of required memory, but Node only has 1.4GB available for the parent process coordination.
You can verify this is your issue by checking memory usage:
$ node --max-old-space-size=512 node_modules/.bin/eslint .
# If this fails faster with memory errors, it confirms the issue
Step 3: Implementing the Solution
Solution 1: Limit Worker Threads
The most immediate fix is to reduce the number of parallel workers ESLint spawns. Create or update your ESLint configuration:
// eslint.config.js
import js from '@eslint/js';
import typescript from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
export default [
{
// Add this settings block at the top level
settings: {
// Limit to 2 worker threads instead of auto-detecting CPU cores
'eslint/max-workers': 2,
},
},
js.configs.recommended,
{
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2024,
sourceType: 'module',
// This is the memory-hungry option
project: './tsconfig.json',
},
},
// ... rest of your config
},
];
However, ESLint v9.34.0 doesn't consistently respect the max-workers setting in the config file. The more reliable approach is using environment variables:
$ ESLINT_MAX_WARNINGS=0 NODE_OPTIONS="--max-old-space-size=4096" npx eslint . --max-warnings 0
Or better yet, create a script in your package.json:
{
"scripts": {
"lint": "NODE_OPTIONS='--max-old-space-size=4096' eslint . --max-warnings 0",
"lint:fix": "NODE_OPTIONS='--max-old-space-size=4096' eslint . --fix"
}
}
This allocates 4GB of heap space to the Node process, giving ESLint room to spawn workers without hitting memory limits.
Solution 2: Disable Multithread for Specific Runs
For one-off linting or CI environments with memory constraints, you can force single-threaded execution:
$ ESLINT_USE_FLAT_CONFIG=true eslint . --no-inline-config
The --no-inline-config flag combined with specific environment settings can sometimes trigger single-threaded mode, but this isn't officially documented. A more reliable method is to use the cache strategically:
# First run: Build cache with limited parallelization
$ NODE_OPTIONS="--max-old-space-size=2048" eslint . --cache --cache-location .eslintcache
# Subsequent runs: Much faster with cache
$ eslint . --cache --cache-location .eslintcache
The cache file stores linting results, so ESLint only processes changed files. This dramatically reduces the memory footprint on subsequent runs.
Solution 3: Optimize TypeScript Parser Configuration
The biggest memory consumer is usually TypeScript project loading. You can significantly reduce memory usage by adjusting parser options:
// eslint.config.js - Optimized version
import js from '@eslint/js';
import typescript from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
export default [
js.configs.recommended,
{
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2024,
sourceType: 'module',
// Remove project reference for faster, lighter linting
// project: './tsconfig.json',
// Use program-less parsing instead
// This disables type-aware rules but uses 70% less memory
},
},
plugins: {
'@typescript-eslint': typescript,
},
rules: {
// Keep rules that don't require type information
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/explicit-function-return-type': 'off',
// These rules REQUIRE project/type information - disable them
// '@typescript-eslint/await-thenable': 'off',
// '@typescript-eslint/no-floating-promises': 'off',
// '@typescript-eslint/no-misused-promises': 'off',
},
},
];
If you absolutely need type-aware linting rules, use a two-pass approach:
{
"scripts": {
"lint": "npm run lint:fast && npm run lint:types",
"lint:fast": "eslint . --config eslint.config.js",
"lint:types": "eslint . --config eslint.types.config.js"
}
}
Create two separate config files:
// eslint.config.js - Fast syntax checking, no type info
export default [
{
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2024,
sourceType: 'module',
},
},
rules: {
'no-unused-vars': 'error',
'no-console': 'warn',
// All non-type-aware rules here
},
},
];
// eslint.types.config.js - Type-aware checking, runs separately
export default [
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
},
},
rules: {
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-floating-promises': 'error',
// Only type-aware rules here
},
},
];
This way, your daily development linting runs fast and light, while type-aware checking runs separately when needed.
Step 4: Working Solution Example
Here's a complete, production-ready setup that solves the multithread memory issue:
// eslint.config.js
import js from '@eslint/js';
import typescript from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import react from 'eslint-plugin-react';
export default [
js.configs.recommended,
{
// Ignore heavy directories
ignores: [
'node_modules/**',
'dist/**',
'build/**',
'.next/**',
'coverage/**',
'**/*.min.js',
],
},
{
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2024,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
// Only include project for type-checking when explicitly needed
...(process.env.ESLINT_TYPE_AWARE === 'true' && {
project: './tsconfig.json',
}),
},
},
plugins: {
'@typescript-eslint': typescript,
'react': react,
},
rules: {
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
}],
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
];
Update your package.json scripts:
{
"scripts": {
"lint": "NODE_OPTIONS='--max-old-space-size=4096' eslint . --cache",
"lint:fix": "NODE_OPTIONS='--max-old-space-size=4096' eslint . --fix --cache",
"lint:types": "ESLINT_TYPE_AWARE=true NODE_OPTIONS='--max-old-space-size=6144' eslint . --cache",
"lint:ci": "NODE_OPTIONS='--max-old-space-size=4096' eslint . --cache --cache-location .eslintcache --max-warnings 0"
}
}
For CI environments like GitHub Actions, add a workflow configuration:
# .github/workflows/lint.yml
name: Lint
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Cache ESLint
uses: actions/cache@v3
with:
path: .eslintcache
key: eslint-${{ runner.os }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
- name: Run ESLint
run: npm run lint:ci
env:
NODE_OPTIONS: '--max-old-space-size=4096'
This configuration ensures ESLint has enough memory in CI while using caching to speed up subsequent runs.
Additional Tips & Related Errors
Error: "Worker exited before finishing bootstrap"
This error appears when worker threads crash during initialization, often due to corrupted cache files:
$ rm -rf .eslintcache node_modules/.cache
$ npm run lint
Error: "Maximum call stack size exceeded"
This happens when ESLint encounters circular dependencies while parsing. Add these files to your ignore patterns:
export default [
{
ignores: [
'**/vendor/**',
'**/*.generated.js',
'**/legacy/**',
],
},
// ... rest of config
];
Performance Monitoring
To see exactly how much memory ESLint is using:
$ /usr/bin/time -v npx eslint . 2>&1 | grep "Maximum resident"
# On macOS, use:
$ /usr/bin/time -l npx eslint . 2>&1 | grep "maximum resident"
Gradual Migration Strategy
If you're dealing with an extremely large codebase (10,000+ files), consider linting incrementally:
# Lint specific directories
$ eslint ./src/components --cache
$ eslint ./src/services --cache
$ eslint ./src/utils --cache
Or use glob patterns to control batch size:
$ find ./src -name "*.tsx" -type f | head -n 1000 | xargs eslint --cache
Memory Profiling
To identify which plugins consume the most memory, run ESLint with Node's profiler:
$ node --inspect node_modules/.bin/eslint .
# Then open chrome://inspect in Chrome to see memory snapshots
This helps you determine if a specific plugin is the culprit and whether you can disable or replace it.