Why your JavaScript build runs out of memory
Two different failures get described as 'the build ran out of memory', and they need different fixes. Telling them apart takes ten seconds and saves you from applying the wrong one.
# Failure A — V8 hit its own heap ceiling
FATAL ERROR: Reached heap limit Allocation failed -
JavaScript heap out of memory
# Failure B — the kernel killed the process
error Command failed with exit code 137.Failure A is Node refusing to grow its heap past a configured limit. Failure B is the operating system's OOM killer terminating the process because the machine ran out of physical memory. Exit code 137 is 128 + 9, meaning SIGKILL, and the important detail is that nothing in your Node process gets a chance to report anything — it simply vanishes mid-line.
If you see A, raising the heap limit may help. If you see B, raising the heap limit makes things actively worse, because you're giving V8 permission to allocate more of memory that does not exist.
What is actually consuming gigabytes
Modern frontend builds are memory-hungry in ways that are not obvious from the size of your source tree. A 40,000-line codebase can peak at 6 GB of resident memory during a build, and here's roughly where it goes.
- TypeScript's type checker holds the entire program graph in memory at once. Type inference is not incremental across a fresh build, and heavy generic types — deeply nested conditional types, large union types, ORM schema types generated from a big database — can dominate everything else.
- Bundlers keep module graphs, source maps, and intermediate ASTs live simultaneously. Source map generation in particular roughly doubles peak usage, and it's on by default in most production configs.
- Static site generation runs your page components for every route. Two thousand generated pages means two thousand renders, and any per-render leak compounds across them.
- Parallelism multiplies everything. A build that spawns one worker per CPU core on a 16-core machine runs sixteen copies of the expensive part, each with its own heap.
- Minifiers hold whole chunks in memory to do their work. A single very large vendor chunk is worse than several medium ones.
Measure before you tune
Guessing here wastes days. Get the actual peak first.
# Peak resident set size of the whole build, in KB (GNU time, not the shell builtin)
/usr/bin/time -v npm run build 2>&1 | grep 'Maximum resident'
# On a machine without GNU time, sample it
( while true; do ps -o rss= -p $BUILD_PID; sleep 1; done ) | sort -n | tail -1
# What V8 thinks its ceiling is, on this machine
node -e "console.log(require('v8').getHeapStatistics().heap_size_limit / 1e9, 'GB')"That last one is worth running on both your laptop and the build machine, because Node sizes its default heap limit from available system memory. Your 32 GB laptop gives V8 a ceiling of around 4 GB; a 2 GB build container gives it a few hundred megabytes. Same code, same Node version, different limit — which is precisely why it builds locally and not in CI.
The fixes, roughly in order of how often they work
Give the build machine more memory
Unglamorous and usually correct. A production build of a mid-sized Next.js or Nuxt application wants 4 GB and is comfortable at 8. Trying to fit it in 2 GB is a fight you can win, but the effort is rarely worth it compared to the cost of the memory for the few minutes a build takes.
This is why PandaStack's app runtime is built on a 4 GB base image rather than something smaller. We started lower and moved up specifically because real Next.js and TypeScript builds were being OOM-killed — the memory is only occupied while the build runs, and being cheap about it just produces confusing failures.
Raise the heap limit — only for failure A
# Applies to Node processes spawned by the build too, unlike a bare node flag
NODE_OPTIONS=--max-old-space-size=6144 npm run buildSet it below your actual available memory, not above. Setting 8192 on a 4 GB machine converts a clean V8 error into an opaque SIGKILL — you have traded a message that tells you what happened for one that doesn't.
Turn down parallelism
Counter-intuitive, and frequently the fix on constrained machines. If your build spawns a worker per core, capping workers cuts peak memory almost proportionally at a modest cost in wall-clock time.
// next.config.js
module.exports = {
experimental: { cpus: 2 }, // cap build workers
productionBrowserSourceMaps: false, // large, and rarely wanted in prod
};
// tsconfig.json — skip type-checking of node_modules .d.ts files
{ "compilerOptions": { "skipLibCheck": true, "incremental": true } }`skipLibCheck` is the single highest-leverage TypeScript flag here. Type-checking every declaration file in `node_modules` costs a lot of memory to find approximately zero bugs in your own code.
Split the type check out of the build
If the bundler and the type checker both run in one process, their peaks stack. Running them as separate sequential commands means each gets the whole machine, one at a time.
{
"scripts": {
"typecheck": "tsc --noEmit",
"build": "next build",
"ci": "npm run typecheck && npm run build"
}
}This also gives you a better failure signal: you learn immediately whether the problem is types or bundling, rather than watching one process die with both possibilities open.
When it's a genuine regression
If a build that used to fit suddenly doesn't, don't reach for flags — find the change. The usual culprits, in the order I'd check them:
- A generated types file that grew enormously. ORM clients generated from a large schema produce very large union and conditional types, and adding tables can make checking them superlinearly more expensive.
- A newly imported library with no tree-shaking, pulling a huge dependency graph into every chunk.
- A `barrel` index file that re-exports everything, defeating tree-shaking across the whole module graph.
- Route count growth in static generation — going from 200 to 5,000 pre-rendered pages changes the shape of the build entirely.
- A dependency upgrade. Bundler and framework major versions do change memory characteristics, sometimes substantially.
Bisecting is genuinely effective here because the signal is loud: the build either fits or it doesn't. A handful of runs across a range of commits will find the change that did it, and the fix is usually a five-line diff rather than an infrastructure decision.
The general shape: read the error carefully enough to know whether V8 or the kernel killed you, measure the actual peak rather than estimating it, and give the build the memory it needs instead of squeezing it into a container sized for the running app. A build's memory profile has almost nothing to do with the memory your app needs to serve requests, and sizing them together is the underlying mistake.
Frequently asked questions
What is the difference between 'JavaScript heap out of memory' and exit code 137?
The heap-out-of-memory error comes from V8 itself: Node hit its own configured heap ceiling and refused to allocate more, and you get a stack trace. Exit code 137 is 128 + 9, meaning the process received SIGKILL — the operating system's OOM killer terminated it because the machine ran out of physical memory. The distinction matters because raising --max-old-space-size can fix the first and makes the second worse, since you are authorising V8 to allocate memory the machine does not have.
Why does my build work locally but run out of memory in CI?
Node sizes its default heap limit from the memory available on the machine. On a 32 GB laptop V8 gives itself roughly a 4 GB ceiling; in a 2 GB build container it gives itself a few hundred megabytes. Same code, same Node version, entirely different limit. Run node -e "console.log(require('v8').getHeapStatistics().heap_size_limit)" on both machines to see the gap. CI machines are also usually more parallel, and a worker-per-core build multiplies peak memory by the core count.
How much memory does a production Next.js build need?
A mid-sized application typically peaks around 3 to 4 GB and is comfortable at 8 GB. Large TypeScript codebases with heavy generated types, or sites pre-rendering thousands of static pages, can go well beyond that. Two gigabytes is achievable with tuning but is a frequent source of confusing failures, which is why our app runtime uses a 4 GB base — the memory is only occupied for the few minutes the build runs.
Does skipLibCheck actually reduce build memory?
Yes, often substantially. Without it, TypeScript type-checks every .d.ts file in node_modules, which on a project with many dependencies means holding a very large amount of type information in memory to find bugs in other people's shipped declarations rather than in your own code. It is one of the highest-leverage single flags for both build memory and build time, and the risk is low because your own code is still fully checked.
Should I just set --max-old-space-size as high as possible?
No. Set it below the memory actually available on the build machine. Setting it above converts a clean, informative V8 error into an opaque SIGKILL from the kernel, so you lose the diagnostic message and gain nothing. The flag raises a ceiling; it does not create memory. If you are already OOM-killed by the operating system, the fix is a bigger machine, less parallelism, or a cheaper build — not a higher limit.
Keep reading
- App hosting on PandaStack — builds run in a 4 GB microVM sized for real framework builds
- Build-time vs runtime environment variables — the other build failure everyone hits
- Deploy a Next.js app with git push
- The OOM killer inside a guest — what happens when the kernel makes this decision for you
- Pricing
49ms p50 cold start. Fork, snapshot, and scale to zero.