</>CodeWithKarani

JavaScript Heap Out of Memory on next build: Find the Real Cause

Karani GeoffreyKarani Geoffrey9 min read

CI has been green for months. Then someone merges a PR that adds three components and a JSON file, and the build dies. Not with a TypeScript error, not with a lint failure, but with a wall of garbage collection statistics and the words JavaScript heap out of memory.

Somebody adds NODE_OPTIONS=--max-old-space-size=8192, the build goes green, and the incident closes. That is not a fix. That is buying time in a currency that inflates: the build got 8GB of headroom and your app is going to grow into it. Six months later the same thing happens and the number goes to 16384 on a runner with 8GB of RAM, at which point the failure mode changes into something even less obvious.

Three completely different things inside next build can exhaust the heap, and they need three different fixes. The first job is not raising the limit, it is finding out which one you have.

look at the last line the build printed before it died. That tells you the phase, and the phase tells you the fix.

  • Died during Creating an optimized production build: bundling. Set experimental.webpackMemoryOptimizations: true and turn off source maps.
  • Died during the type-checking step: move tsc out of the build into its own CI job.
  • Died during Generating static pages: you are prerendering too many routes at once. Reduce what generateStaticParams returns, or move those routes to on-demand rendering.
  • FATAL ERROR: Reached heap limit is V8 hitting its own ceiling. Exit code 137 with no such message is the kernel or Docker killing the process. Different problem, different fix.

The exact error

<--- Last few GCs --->

[3421:0x5f8a2e0]   184213 ms: Mark-Compact 4041.2 (4130.4) -> 4038.9 (4131.2) MB, 2410.1 / 0.0 ms  (average mu = 0.107, current mu = 0.021) allocation failure; scavenge might not succeed

<--- JS stacktrace --->

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

The interesting part is not the last line, it is the average mu figure just above it. That is the fraction of time V8 spent doing useful work rather than garbage collecting. A value like 0.107 means the process spent roughly ninety percent of its final moments collecting garbage and getting almost nothing back. By the time you see this message the build had been dying for a while.

The one distinction that changes everything

What you seeWho killed itWhat to do
FATAL ERROR: Reached heap limit Allocation failedV8 hit its configured old-space limitRaise --max-old-space-size or reduce what the build holds in memory
Exit code 137, or just Killed, with no FATAL ERRORThe kernel OOM killer or the container runtimeRaise the container/runner memory limit, or lower --max-old-space-size

That second row catches people out badly. If you set --max-old-space-size=16384 on a runner with 8GB of RAM, you have told V8 it may grow to 16GB before it gets serious about collecting. V8 believes you, stops being frugal, and the kernel kills the process at around 8GB with no JavaScript error at all. The build now fails earlier and with a less useful message than before you "fixed" it. If you want the background on why the kernel does this and what it picks, I wrote about what actually happens when a server runs out of RAM.

A related trap: Node does not always see the container's memory limit. Inside Docker or a Kubernetes pod, the default heap sizing can be based on the host's memory rather than the cgroup limit, so V8 happily plans for a machine that it does not have. Set the limit explicitly in containers rather than relying on the default.

Identify the phase

Where next build spends memory 1. Bundling last log line: Creating an optimized production build fix: webpackMemoryOptimizations, disable source maps, disable webpack cache 2. Type checking last log line: mentions checking validity of types fix: run tsc as its own CI job, not inside next build 3. Static generation last log line: Generating static pages (412/9000) fix: prerender fewer routes, defer the rest to on-demand rendering Raising the heap limit is the same action for all three, which is why it hides the real cause so well.
The phase that died is the only diagnostic you need before you start changing config.

Step 1: Read the last line, then confirm with instrumentation

Since Next.js 14.2.0 there is a flag that prints heap usage and GC statistics continuously through the build:

next build --experimental-debug-memory-usage

It also takes heap snapshots automatically as memory approaches the configured limit, and you can trigger one at any moment by sending SIGUSR2 to the process. Snapshots are written to the project root and open in the Chrome DevTools Memory tab. One caveat from the Next.js docs: this mode is not compatible with the webpack build worker, which is auto-enabled when you have no custom webpack config.

For a phase-level view of where allocations come from, record a heap profile instead:

node --heap-prof node_modules/next/dist/bin/next build

That writes a .heapprofile file at the end of the build which loads into the same DevTools Memory tab.

Step 2: Set the heap limit deliberately, in the right place

Pick a number that is roughly 70 to 80 percent of the memory actually available to the process, not a round number that sounds generous.

{
  "scripts": {
    "build": "NODE_OPTIONS=--max-old-space-size=3072 next build"
  }
}

In a Dockerfile:

ENV NODE_OPTIONS="--max-old-space-size=3072"
RUN npm run build

In GitHub Actions:

- name: Build
  run: npm run build
  env:
    NODE_OPTIONS: --max-old-space-size=6144

Two things people get wrong here. Putting NODE_OPTIONS in .env or .env.production does nothing, because those files are read by your application at runtime, not by the shell that launches Node. And the inline NODE_OPTIONS=... next build form in a package.json script is not portable to Windows shells; use cross-env if that matters to your team.

Step 3a: If it died while bundling

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    webpackMemoryOptimizations: true,
    serverSourceMaps: false,
  },
  productionBrowserSourceMaps: false,
}

module.exports = nextConfig

experimental.webpackMemoryOptimizations is available from Next.js 15.0.0 and reduces peak webpack memory at the cost of slightly longer compilation. Source map generation is a real memory cost and most teams do not actually consume browser source maps in production; if you upload them to an error tracker, do that from a separate build rather than from the one that is failing.

If you have a custom webpack config, the build worker that normally isolates compilation in a child process is not auto-enabled. Turn it back on:

experimental: { webpackBuildWorker: true }

And if you are still short, the webpack cache is a memory consumer worth sacrificing in CI, where it usually is not being reused anyway:

webpack: (config, { dev }) => {
  if (config.cache && !dev) {
    config.cache = Object.freeze({ type: 'memory' })
  }
  return config
}

Step 3b: If it died during type checking

TypeScript's checker holds the whole program graph in memory and on a large codebase that is easily gigabytes. The right answer is not to make the build tolerate it, it is to stop doing two expensive things in one process:

- name: Typecheck
  run: npx tsc --noEmit
- name: Build
  run: npm run build
  env:
    NODE_OPTIONS: --max-old-space-size=6144

Then, and only then, let the build skip its own type check:

typescript: {
  // Only safe because a dedicated CI step runs tsc --noEmit and gates the deploy.
  ignoreBuildErrors: true,
}

Write that comment. Without a separate gate, this flag means you can ship a build with type errors in it, and someone will eventually delete the CI step without realising what it was protecting.

Step 3c: If it died generating static pages

This is the one that has nothing to do with your bundler and everything to do with your data. A generateStaticParams that returns 40,000 slugs asks the build to render 40,000 pages, and whatever each of those renders retains stays retained for the duration.

Ask a blunt question: does every one of those pages need to exist before the first user asks for one? Usually the honest answer is that a few hundred get traffic. Return only those from generateStaticParams and let the rest render on demand and be cached from that point:

export async function generateStaticParams() {
  const top = await getMostRequestedSlugs({ limit: 500 })
  return top.map((slug) => ({ slug }))
}

// Anything not in the list above renders on first request and is then cached.
export const dynamicParams = true

Also look for module-scope data. A import data from './everything.json' at the top of a page module is loaded once and held for the entire build. Move it inside the function that needs it, or read it from disk per render, so it can be collected.

If you are on a version that uses source maps during the prerender phase, the Next.js docs note you can disable that specifically with enablePrerenderSourceMaps: false when memory problems start after "Generating static pages".

Verification

1. The build passes with a lower limit than before. This is the test that distinguishes a fix from a deferral. If your build needed 8GB and now completes at 3GB, you fixed something. If it still needs 8GB, you moved the problem.

NODE_OPTIONS=--max-old-space-size=3072 npm run build

2. Measure peak memory, do not guess it. On Linux:

/usr/bin/time -v npm run build 2>&1 | grep 'Maximum resident set size'

That prints peak RSS in kilobytes. Record the number in your build docs. It is the only way to notice the trend before it becomes an outage.

3. Make the ceiling explicit in CI. Set the heap limit in the workflow rather than leaving it to whatever the runner defaults to. A build that only passes because the runner happens to be generous today is a build that will fail on a different runner tomorrow.

What people get wrong

The moveWhy it backfires
--max-old-space-size=16384 on an 8GB runnerV8 stops collecting aggressively, the kernel kills the process at 8GB, and now you get exit 137 with no JavaScript error to debug.
Putting NODE_OPTIONS in .envThose files are read by the app at runtime, not by the shell that starts the build. The setting is silently ignored.
ignoreBuildErrors: true with no separate typecheckYou have not saved memory, you have removed the check. Type errors now reach production.
Upgrading to a bigger CI runner as the first moveIt works, it costs money every month, and it hides growth in the build until the next ceiling. Fine as a stopgap, not as a diagnosis.
Deleting node_modules and reinstallingCargo cult. Nothing about a fresh install changes what webpack holds in memory during compilation.
Assuming it is a Next.js bugThis is a V8 error, not a Next.js one. It means something in your build genuinely allocated more than the limit allowed.

Worth saying plainly: throwing hardware at this is a real option and sometimes the correct one. But if you are building on a modest VPS because a bigger CI plan is a meaningful monthly cost, the phase-specific fixes above are not a consolation prize. Splitting tsc out of the build and trimming generateStaticParams routinely takes a build from needing 8GB to needing 2GB, and that difference is the difference between a build server you can afford and one you cannot.

When it is still broken

  1. You get exit 137 and no FATAL ERROR. That is not V8. Check the Docker daemon's memory limit, the Kubernetes pod limit, or dmesg | tail on a VM for the OOM killer's log entry. Raising --max-old-space-size makes this worse, not better.
  2. It only fails in Docker. Docker Desktop applies its own memory ceiling to the VM. Check that setting before you change anything in your code. If you are new to how build-time and run-time memory differ inside images, the practical Docker on-ramp covers the mental model.
  3. A single dependency dominates the heap profile. Barrel files that re-export an entire icon or component library pull far more into the graph than the three things you imported. Import from the specific path instead, and use the bundle analyzer to confirm the change did something.
  4. It fails only on one branch. Diff the config, not the code. A next.config.js change that switched on source maps or added a webpack plugin is the usual culprit, and it will not look like a memory change in review.

Frequently asked questions

What does 'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory' mean during next build?
It means V8 hit the maximum size it was allowed to grow its old-space heap to and could not free enough memory to continue. It is a Node.js error rather than a Next.js one, so it tells you something in the build allocated more than the limit permitted, not which part. Look at the last line the build printed before it died to identify whether the phase was bundling, type checking or static page generation.
How much should I set --max-old-space-size to for a Next.js build?
Roughly 70 to 80 percent of the memory actually available to the process, and no more. Setting it above the machine or container limit is actively harmful because V8 stops collecting aggressively and the kernel kills the process instead, producing exit code 137 with no JavaScript error to debug. In containers set it explicitly, because Node does not always observe the cgroup limit.
Why does my Next.js build run out of memory during 'Generating static pages'?
Because generateStaticParams is returning more routes than the build can render while holding their output in memory. Return only the routes that genuinely need to exist before the first request, leave dynamicParams enabled so the rest render on demand and get cached, and move any large module-scope data imports inside the functions that use them so they can be garbage collected.
Is it safe to set typescript.ignoreBuildErrors to true to fix build memory issues?
Only if a separate CI step runs tsc --noEmit and gates the deploy on it. TypeScript's checker holds the whole program graph in memory and is often the single largest consumer during a build, so splitting it into its own job is a legitimate fix. Setting the flag without that separate check means type errors reach production, and the next person to touch the pipeline will not know the step was load-bearing.
#Next.js#Node.js#CI/CD#Webpack#Memory
Keep reading

Related articles