Ineffective mark-compacts near heap limit: npm build fix
Find the heap cap your build actually runs under, raise it in the place that reaches the crashing process, and avoid the two spellings that silently change nothing.
TL;DR#
One node process in the build ran out of V8 heap. Node gives every process a ceiling on its old-generation heap. When the compiler or bundler keeps more live objects than that ceiling allows, V8 spends almost all its time in full garbage collections that free nothing, gives up, and aborts. First find which process died. If the crash is in TypeScript and the heap stopped just under 2048 MiB, as in the source question, it is most likely the forked type checker of fork-ts-checker-webpack-plugin (used by react-scripts-ts and react-scripts): its cap comes from the plugin's memoryLimit option, and NODE_OPTIONS does not reach it. For any other build process, raise the ceiling with --max-old-space-size through NODE_OPTIONS, so every node process npm starts inherits it. Either way, keep the value below the memory the machine or container really has.
The error#
The Stack Overflow question shows a React and TypeScript project on Windows 10. Its react-scripts-ts build started failing after node_modules was deleted and reinstalled:
[11376:0000024682F49880] 60039 ms: Mark-sweep 2034.1 (2085.2) -> 2033.7 (2085.2) MB, 1029.4 / 0.0 ms (average mu = 0.073, current mu = 0.006) allocation failure scavenge might not succeed
[11376:0000024682F49880] 61094 ms: Mark-sweep 2034.4 (2085.2) -> 2034.1 (2085.7) MB, 1047.7 / 0.0 ms (average mu = 0.039, current mu = 0.007) allocation failure scavenge might not succeed
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memoryThe JS stack trace in that log stops inside TypeScript's binder (bindChildrenWorker in node_modules\typescript\lib\typescript.js), with about 2,034 MB of heap in use. Each full collection takes over a second and frees almost nothing.
That figure matters. react-scripts-ts 3.1.0 depends on fork-ts-checker-webpack-plugin ^0.2.8 and creates the plugin in its production webpack config with only async, tsconfig and tslint, no memoryLimit. A fresh install of that range resolves to 0.2.10, whose source sets DEFAULT_MEMORY_LIMIT = 2048 and forks the checker with --max-old-space-size= plus that limit. Both were read from the packages on npm on 10 October 2026, and the raw grep output is in the reproduction log. A heap that dies at about 2,034 MB, inside TypeScript's binder, matches that 2048 MiB checker, not the main build process. For that reader, the fix is in the type checker section below, not in NODE_OPTIONS.
The same failure is easy to reproduce. A small npm project whose build script keeps a growing set of objects alive was run under a 64 MiB cap. The run used Node 22.23.3 in the official node:22-bookworm-slim image, with Docker 29.7.2 and a 1 GB container limit, on 10 October 2026:
heap_size_limit MB: 76
<--- Last few GCs --->
[19:0x2af8c000] 1527 ms: Mark-Compact 64.6 (71.0) -> 60.8 (71.3) MB, pooled: 0 MB, 29.07 / 0.00 ms (average mu = 0.142, current mu = 0.036) allocation failure; scavenge might not succeed
[19:0x2af8c000] 1558 ms: Mark-Compact 64.7 (71.3) -> 60.9 (71.3) MB, pooled: 0 MB, 26.11 / 0.00 ms (average mu = 0.147, current mu = 0.151) allocation failure; scavenge might not succeed
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memoryThe process exited with code 134 (Aborted (core dumped)). The question's log says "Mark-sweep" where Node 22.23.3 printed "Mark-Compact"; both lines report full collections that freed almost nothing.
Why V8 gives up before the heap is technically full#
The message comes from Heap::CheckIneffectiveMarkCompact in V8's heap.cc. After every full mark-compact, V8 asks two things. Is the old generation at or above 95 % of its maximum size? Is the mutator utilisation (the share of time your JavaScript runs rather than the collector) below 40 %? Both thresholds are defaults in flag-definitions.h. When the answer is yes four times in a row, V8 first calls the near-heap-limit callback. If that callback does not raise the limit, V8 aborts with "Ineffective mark-compacts near heap limit". In the log, mu is that utilisation: 0.006 and 0.007 in the question, meaning the process was doing almost nothing but collecting garbage.
The same file has a sibling message, "Reached heap limit". It fires when the old generation cannot expand at all. Both mean the same thing for a build: the live data does not fit under the cap. More garbage collection will not save it.
The cap itself is --max-old-space-size, which the Node.js CLI docs describe as the max memory size of V8's old memory section. Without the flag, Node picks a default, and on Node 22 and 24 that default followed the memory limit of the container in these runs. Measured in Docker with no flag:
Container --memory | Node 22.23.3 default limit | Node 24.21.0 default limit |
|---|---|---|
| 512m | 259 MiB | 259 MiB |
| 1g | 524 MiB | 560 MiB |
| 2g | 1048 MiB | not measured |
| 4g | 2007 MiB | 2151 MiB |
That is why a build that passed on a developer laptop can fail on a 2 GB CI runner or a small container, even though nothing changed in the code.
Fix#
1. See the limit your build actually gets#
Run this in the same shell, with the same environment, that runs npm run build. It prints the heap cap, in MiB, that the next node process will get:
node -p "Math.round(require('v8').getHeapStatistics().heap_size_limit / 1048576)"If the number is close to the heap size in your crash log, the main build process simply needs more room than it has. If it is not, and your crash log stops near 2048 MiB inside TypeScript, the process that died is probably a forked checker with its own cap: go to the type checker section.
2. Raise the cap with NODE_OPTIONS#
NODE_OPTIONS is read by every node process, including the ones npm, react-scripts or webpack start. Pick a value in MiB that fits the machine. The Node docs suggest 1536 on a machine with 2 GiB of RAM.
Linux, macOS, Git Bash, CI shells:
export NODE_OPTIONS=--max-old-space-size=4096
npm run buildWindows Command Prompt, the form from the question's third answer:
set NODE_OPTIONS=--max_old_space_size=8192
npm run buildWindows PowerShell uses the $Env: syntax from about_Environment_Variables:
$Env:NODE_OPTIONS = "--max-old-space-size=4096"
npm run buildHyphens and underscores name the same V8 flag. With --max_old_space_size=256 the reproduction reported a limit of 268 MiB, the same as with hyphens. The Windows commands above were not run for this article. The reproductions ran in Linux containers.
In the reproduction, this single change took the same npm run build from the abort above to a finished build:
> heap-demo@1.0.0 build
> node build.js 1000000
heap_size_limit MB: 268
bound symbols: 1000000 | heapUsed MB: 154 | build finished3. Or put the flag on the node command in package.json#
When your build script calls node itself, the flag can live in the script, with no environment variable at all. This is the reproduction's own script; replace build.js with your entry file:
{
"scripts": {
"build:flag": "node --max-old-space-size=256 build.js 1000000"
}
}This finished in the reproduction with the same 268 MiB limit. It does not work the other way round. Running node --max-old-space-size=256 npm-cli.js run limit raises the cap of the node process that runs npm, and the script's own node process still reported the default 524 MiB. A wrapper such as react-scripts build spawns a new node process for its build script, so for wrappers use NODE_OPTIONS.
An inline NODE_OPTIONS=… react-scripts build inside package.json only works on POSIX shells: npm runs scripts with /bin/sh on Unix-like systems and cmd.exe on Windows by default. The cross-env package exists to set variables the same way on both.
4. Keep the cap under the real memory#
The flag lets the heap grow. It does not add RAM. In a 128 MiB container with the cap set to 4096, Node accepted the value, and the container's memory limit killed the process mid-build (Docker reported OOMKilled=true):
heap_size_limit MB: 4099
[exit code: 137]There was no V8 message at all, only exit code 137. Under react-scripts, the launcher prints "The build failed because the process exited too early" when its build process receives SIGKILL. If raising the cap turns a FATAL ERROR into a silent kill, the machine or container needs more memory. Node 22.23.3 and 24.21.0 both also accepted the --max-old-space-size-percentage flag, which sets the cap as a share of available memory: 75 gave 780 MiB and 816 MiB in a 1 GB container.
Verify the fix#
Re-run the one-liner from step 1 in the shell that will run the build. It must print roughly the value you set, plus a few MiB: 2060 for a cap of 2048 in the reproduction. Then run npm run build again. A successful build exits with code 0. If the number did not move, the variable did not reach Node, and the next section is the likely reason.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
The en dash that changes nothing#
The question quotes the flag as NODE_OPTIONS="–max-old-space-size=2048", with an en dash (U+2013) where two hyphens belong. That is what happens when a flag is copied from a page that turns -- into a dash. In the reproduction, Node 22.23.3 and 24.21.0 both started normally with that value, printed no warning, and kept the default limit:
$ docker run --memory=1g -e NODE_OPTIONS=–max-old-space-size=2048 node:22-bookworm-slim node -p "Math.round(require('v8').getHeapStatistics().heap_size_limit / 1048576)"
524With two ASCII hyphens, the same command printed 2060. If "I set it and nothing changed" sounds familiar, retype the two hyphens by hand.
When the type checker has its own cap#
NODE_OPTIONS does not win everywhere. Node's docs say a singleton flag passed on the command line overrides the same flag passed in NODE_OPTIONS. fork-ts-checker-webpack-plugin runs TypeScript checking in a forked worker. In its 6.5.0 package on npm and on its main branch, it forks that worker with execArgv set to --max-old-space-size= followed by its memoryLimit option, which defaults to 2048. Create React App's react-scripts uses this plugin for TypeScript projects without setting memoryLimit, and react-dev-utils depends on it at ^6.5.0. The older 0.2.x line used by react-scripts-ts does the same: a single checker is forked with --max-old-space-size= set from memoryLimit, default 2048.
A forked child with its own flag ignores the inherited value, as this reproduction of the same fork pattern shows:
webpack parent NODE_OPTIONS = --max-old-space-size=512 | heap_size_limit MB: 524
checker child NODE_OPTIONS = --max-old-space-size=512 | heap_size_limit MB: 140When the checker is the process that dies, version 6.5.0 of the plugin prints "Issues checking service aborted - probably out of memory" and points at memoryLimit. If you own the webpack config, raise it there (this is the option shape of version 6 and later):
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
module.exports = {
plugins: [
new ForkTsCheckerWebpackPlugin({
typescript: { memoryLimit: 4096 },
}),
],
};react-scripts-ts, the tool in the source question, does not expose its webpack config, so neither NODE_OPTIONS nor a config option reaches its checker as installed. Its 3.1.0 package ships an eject script (npm run eject in an app created with it), which copies the config into the project. After that, the plugin call in config/webpack.config.prod.js can take the limit. In the 0.2.x line, memoryLimit is a top-level option, read as options.memoryLimit in its source:
// config/webpack.config.prod.js, inside the plugins array
new ForkTsCheckerWebpackPlugin({
async: false,
tsconfig: paths.appTsProdConfig,
tslint: paths.appTsLint,
memoryLimit: 4096,
});The eject script asks "Are you sure you want to eject? This action is permanent.", so commit first. Keep the new limit below the machine's free memory: the checker runs alongside the webpack process, and each has its own heap.
The checker in react-scripts already forces skipLibCheck on. What that option does, and why exclude alone does not stop tsc checking dependencies, is covered in the skipLibCheck post for tsc.
The same crash in other builds#
The mechanism does not depend on React. A Next.js build that runs out of heap gets the same treatment: the NODE_OPTIONS variant is listed in the Turbopack troubleshooting post. A Next.js server that keeps restarting near its limit is a leak to find rather than a cap to raise: see the out-of-memory restart fix. On Windows, if the build stops earlier because the compiler itself will not start, see tsc is not recognized. If a Docker build stops with sh: tsc: not found instead of a heap error, the cause is a missing tsc binary in the build stage, not memory.
When the error appears, read it as a measurement: the build's live data did not fit under the heap cap that one node process received. Find that process and print its cap. Raise the cap where that process reads it: NODE_OPTIONS for most builds, memoryLimit for a forked type checker, and always below the memory the machine really has. If the build still needs more heap than any sensible cap allows, the problem is the build's size, and more memory flags will not fix it.
Further reading: Fix dotenv: command not found in a NestJS start script. Also see Fix pnpm Ignored Build Scripts for Native Dependencies.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix gyp: No Xcode or CLT version detected! in npm install
Learn which of the three checks node-gyp runs on macOS is failing, repair the Command Line Tools, and get native modules compiling again.
Fix tsc is not recognized: run the local compiler
Prove that TypeScript is installed, then make Command Prompt and VS Code build with that project version without a global compiler.
Fix dotenv: command not found in a NestJS start script
Get a NestJS start:dev script that calls dotenv -e running again, and avoid the dotenv 18 binary that now answers with a usage screen instead.


