Your build runs for a while, slows to a crawl, then dies with FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory, usually preceded by a block of Mark-Compact garbage collector lines. It often works locally and fails only in CI. This guide covers the quick fix for the JavaScript heap out of memory error, then the underlying causes, so you are not just raising a limit until it breaks again.
The error means V8, the JavaScript engine in Node.js, hit the maximum size of its “old space” heap and could not free enough memory to continue. It is a ceiling on JavaScript objects, not necessarily on your machine’s total RAM.
Quick fix
The most likely cause is that your build legitimately needs more heap than Node’s default allows. Raise the limit with --max-old-space-size (value in megabytes) via NODE_OPTIONS, so it applies to every Node process the build spawns:
- Check how much memory the machine or CI runner actually has, and leave headroom for the OS and other processes.
- Set the heap limit for the build command:
# macOS / Linux
NODE_OPTIONS="--max-old-space-size=4096" npm run build
# Windows PowerShell
$env:NODE_OPTIONS="--max-old-space-size=4096"; npm run build
- Make it permanent and cross-platform in
package.jsonwithcross-env:
{
"scripts": {
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 next build"
}
}
If the build now passes, you are done for today, but read on. A build that needed 4 GB last month and needs 6 GB this month is telling you something.
Causes, from most to least common
1. The default heap is too small for the project
Node sizes its default heap based on the memory available to it, and the default is conservative. Large TypeScript projects, monorepos and big Next.js or Vite apps can exceed it during type checking, bundling and minification. This is the case the quick fix solves.
2. CI runner limits lower than your laptop
Your laptop may have 16 or 32 GB; a hosted CI runner or a small VPS may have far less. GitHub-hosted runners, Vercel, Netlify and Docker build containers all publish their memory limits, and private-repository runners are often smaller than public ones. Check your provider’s current specs rather than assuming. In GitHub Actions, set the variable on the step:
- name: Build
run: npm run build
env:
NODE_OPTIONS: --max-old-space-size=6144
If the runner simply doesn’t have enough RAM, use a larger runner or reduce what the build does.
3. Source maps for huge bundles
Generating source maps for large production bundles is memory hungry, because the bundler holds mappings for every module at once. Check whether you really need full production source maps, especially browser source maps.
// vite.config.js
export default {
build: {
sourcemap: false, // or 'hidden' if you upload maps to an error tracker
},
};
// next.config.js
module.exports = {
productionBrowserSourceMaps: false, // the default; check it wasn't turned on
};
4. Generating too many pages at once
Static site generation across thousands of pages (product catalogues, programmatic SEO pages, large headless WordPress sites) can keep a lot of data alive. Common culprits are fetching the full dataset in every page’s data function, or a module-level cache that grows with every page and is never cleared.
// app/products/[slug]/page.tsx
// Pre-render only the most important pages at build time;
// the rest render on first request and are cached.
export async function generateStaticParams() {
const top = await getTopProducts({ limit: 500 });
return top.map((p) => ({ slug: p.slug }));
}
export const dynamicParams = true;
If you use a headless CMS, request only the fields each page needs. My headless WordPress with Next.js guide covers this in more detail.
5. Bloated dependencies and barrel imports
Importing from large “barrel” index files (icon sets, utility libraries, component kits) can force the bundler to parse thousands of modules you never use. A bundle analyser shows what is actually being pulled in:
# Next.js
npm i -D @next/bundle-analyzer
ANALYZE=true npm run build
# Vite / Rollup
npm i -D rollup-plugin-visualizer
// Prefer direct imports where the library supports them
import debounce from 'lodash/debounce';
// instead of: import { debounce } from 'lodash';
Next.js also has an optimizePackageImports option for this; check the current docs for which packages it handles automatically.
6. Memory leaks in your own build scripts (the one people miss)
Custom scripts that generate sitemaps, search indexes, image manifests or OG images often accumulate everything in an array before writing it out, or keep open handles inside loops. Raising the heap only delays the crash as content grows. Stream results instead of building one giant object:
// scripts/build-sitemap.mjs
import { createWriteStream } from 'node:fs';
const out = createWriteStream('public/sitemap.xml');
out.write('<?xml version="1.0" encoding="UTF-8"?>n');
out.write('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">n');
for await (const page of fetchPagesInBatches(500)) {
for (const p of page) {
out.write(` <url><loc>${p.url}</loc></url>n`);
}
}
out.end('</urlset>n');
7. Setting the limit higher than the container allows
A subtle trap: if you set --max-old-space-size=8192 inside a container with 4 GB, Node happily grows past what the container permits and the kernel kills it. You then see exit code 137 or “Killed” instead of the heap error, which sends people chasing the wrong problem. Keep the heap limit below the container or runner memory, leaving room for the bundler’s native memory and worker processes.
8. An old Node.js version
V8’s garbage collector and Node’s memory defaults have improved across releases, and bundlers tune for current LTS versions. If you are several major versions behind, upgrading to a current LTS release is a reasonable step after the checks above. Use .nvmrc or the engines field so CI and local builds use the same version.
Step-by-step diagnosis
- Confirm which process dies. The stack trace and the lines above it show whether it is the bundler, the TypeScript checker, a test runner or your own script.
- Check that the flag is applied. Print the effective heap limit from inside the build environment:
node -e "console.log(require('v8').getHeapStatistics().heap_size_limit / 1024 / 1024 + ' MB')"
- Watch memory over time. Run with
--trace-gcor logprocess.memoryUsage().heapUsedin your scripts. Steady growth that never drops points to a leak; a single spike points to one heavy step. - Capture a heap snapshot near the limit. Node can write one automatically, which you then open in Chrome DevTools’ Memory tab:
node --heapsnapshot-near-heap-limit=2 --max-old-space-size=4096
node_modules/next/dist/bin/next build
- Bisect the build. Disable source maps, cut the number of statically generated pages, or skip type checking temporarily. Whichever change makes memory drop is your culprit.
- Check framework tooling. Recent Next.js versions include memory-related build options and a debug memory flag; check the “Memory usage” page in the Next.js docs for the version you are on.
Prevention
- Pin the Node version in
.nvmrcand CI, and keep it on a supported LTS line. - Set
NODE_OPTIONSexplicitly in CI with a value that fits the runner, rather than relying on defaults. - Run a bundle analyser periodically and after adding large dependencies.
- Stream or batch large data processing in build scripts.
- Generate only high-value pages at build time and render the long tail on demand.
- Run type checking as a separate CI step so it doesn’t compete with the bundler for memory.
Key takeaways
- The error means the V8 heap hit its limit, not necessarily that the machine ran out of RAM.
NODE_OPTIONS="--max-old-space-size=4096"is the quick fix; set it in CI too.- Never set the heap limit above what the container or runner actually has, or you get exit code 137 instead.
- Source maps, mass static generation, heavy dependencies and leaky build scripts are the real causes to fix.
- Heap snapshots and
--trace-gctell you where memory goes before you guess.
Frequently asked questions
What value should I use for –max-old-space-size?
Use a value comfortably below the memory available to the build, such as 4096 on an 8 GB machine or runner. Leave headroom for the operating system, native bundler memory and any worker processes.
Why does the build work locally but fail in CI?
CI runners and build containers usually have less memory than a developer laptop, so Node gets a smaller default heap and less room overall. Set NODE_OPTIONS in the CI step and check the runner’s published memory limits.
Is increasing the heap size a permanent fix?
It is fine when the project is simply large, but if memory use keeps growing with each release, you likely have a leak or an unbounded build step. Diagnose it with heap snapshots before the limit runs out again.
What is the difference between this error and exit code 137?
The heap error comes from V8 hitting its configured heap limit, while exit code 137 means the operating system or container killed the process for using too much total memory. Exit code 137 usually means the heap limit is set higher than the container allows.
If your builds keep falling over and you would rather have someone dig into the pipeline for you, contact me and I can profile the build and fix the root cause.