
Next.js Optimizing memory usage
Every Next.js project starts out light. A handful of routes, a couple of dependencies, a next dev process that boots in under a second. Then, six months and forty pull requests later, next build is taking twice as long as it used to, your CI runner is getting killed with an out-of-memory error at seemingly random points in the pipeline, and next dev itself starts to feel sluggish after a few hours of hot reloading. None of this happens because you did something obviously wrong — it happens because memory pressure in a Next.js project accumulates gradually, from dozens of small sources that are each individually reasonable.
The frustrating part is that "memory usage" doesn't show up as a single, obvious metric the way bundle size or Lighthouse score does. There's no dashboard that tells you "your build process is using 1.8GB of heap and climbing." You mostly find out about it the hard way: a build that dies with JavaScript heap out of memory, a dev server that needs restarting every afternoon, or a CI job that passes locally but OOMs on a memory-constrained runner. This article walks through the actual toolkit Next.js gives you for finding and fixing these problems, in both development and at build time, along with the reasoning for why each one works and when it's worth reaching for.
Why This Doesn't Show Up Until It's a Problem
Two separate processes consume memory in a Next.js project, and it's worth keeping them mentally separate because the fixes for each are different.
The first is the build/compile process — Webpack or Turbopack parsing your source, resolving your dependency graph, running the TypeScript compiler, generating source maps, and prerendering your static pages. This process runs once per build (or continuously, in a lighter form, during next dev), and its memory footprint scales with the size and complexity of your codebase and dependency tree.
The second is the running server process — the Node.js process that actually serves your application once it's built. This one preloads page modules into memory at startup, and its footprint scales with how many routes exist and how much of your app's code has actually been requested (and therefore loaded) at any given point.
Most of the pain people report is on the build side, because build memory issues are the ones that actually crash a process with an exit code instead of just quietly making things slower. A dev server that's using more RAM than it should will still limp along; a build process that runs out of heap will hard-fail with a stack trace that looks intimidating but is almost always solvable with the techniques below.
Start by Reducing What You're Actually Compiling
Before reaching for any flags or experimental options, it's worth asking the boring question first: how much stuff is actually in your dependency graph? Every package you import — even indirectly, through a UI library that pulls in its own tree of dependencies — is source code that Webpack or Turbopack has to parse, transform, and hold in memory while compiling.
Next.js ships a first-party bundle analyzer specifically for this kind of investigation:
npm install --save-dev @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
module.exports = withBundleAnalyzer({});
Run it with:
ANALYZE=true npm run build
This opens an interactive treemap of your client and server bundles. The point isn't just to shrink shipped JavaScript (though that's a nice side effect) — it's to notice the packages that are disproportionately large relative to what you actually use from them. A date library that pulls in every locale, a UI kit imported as a single barrel export instead of per-component, an SDK that bundles an entire HTTP client you don't need server-side — these all inflate both your bundle and your build-time memory footprint. Trimming them is the single highest-leverage thing you can do before touching any Next.js-specific memory flag, because it reduces the input to every other tool in this article, rather than just working around the symptom.
Turn On Webpack's Memory Optimizations Flag
If you're still on Webpack (rather than Turbopack) for your builds, Next.js has an experimental flag specifically aimed at reducing peak memory during compilation:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
webpackMemoryOptimizations: true,
},
};
module.exports = nextConfig;
This changes internal Webpack behavior to trade a small increase in compile time for a real reduction in peak heap usage. It's marked experimental, but the Next.js team categorizes it as low-risk — it's been tested widely enough that turning it on isn't a gamble in the way some experimental flags are. If your builds are memory-constrained and you haven't tried this yet, it's close to a free win: flip it on, rerun your build, and see if your peak memory numbers move. There's no code change required anywhere else in your project, which makes it a good first experiment before you invest time in deeper profiling.
Get Next.js to Tell You What's Actually Happening
Guessing at memory problems is slow. The fastest path to an actual fix is to get real numbers out of your build process, and Next.js has a purpose-built flag for exactly this:
next build --experimental-debug-memory-usage
Running your build this way makes Next.js continuously print heap usage and garbage collection statistics throughout the entire build — not just a final number, but a stream of data points you can watch as different build phases execute. This alone is often enough to answer the question "which phase of my build is actually the memory hog" — dependency resolution, type checking, and static page generation all have very different memory profiles, and if you're currently treating "the build" as one monolithic thing, this flag is what breaks that assumption.
It goes further than just printing numbers, though: when memory usage approaches whatever limit Node.js has been configured with, it automatically takes a heap snapshot for you. That snapshot is a file you can load directly into Chrome DevTools to see exactly what's sitting in memory at the moment things got tight — which is a much better starting point than reasoning about it in the abstract.
One caveat worth knowing before you reach for this: it isn't compatible with the Webpack build worker (covered below), which is enabled by default in current Next.js versions if you don't have a custom Webpack config. If you're not seeing the memory printouts you expect, that's the first thing to check — the build worker and this debug flag can't run together.
Record a Heap Profile for a Full Build
The debug flag above is great for a live, in-progress view. If you want a complete recording of a build's memory behavior from start to finish that you can load and pick apart afterward, Node.js's own --heap-prof flag does that job, applied directly to the Next.js CLI binary:
node --heap-prof node_modules/next/dist/bin/next build
When the build finishes, you'll have a .heapprofile file sitting in your project root. Open Chrome DevTools, go to the Memory tab, and use "Load Profile" to pull it in. What you get is a full timeline of allocations across the entire build, which lets you correlate spikes in memory with specific build phases far more precisely than watching a live number tick upward.
This is the tool I reach for when the debug-memory-usage flag's printed numbers tell me that memory is spiking during, say, static page generation, but I need to know which specific route or component is responsible. A heap profile recorded across the whole build gives you that resolution — you can zoom into the exact window where the spike happened and see the call stack that was allocating at that moment.
Attach a Live Debugger for Interactive Inspection
Sometimes you don't want a full recorded profile — you want to pause the process at a specific moment and poke around interactively, the way you'd debug application logic. Node's inspector protocol supports this for both next build and next dev:
NODE_OPTIONS=--inspect next build
This exposes the debugging protocol on the default port, and you connect to it from Chrome DevTools (or any other tool that speaks the V8 inspector protocol) the same way you would for debugging application code. If you want the process to pause immediately, before any of your project's code has even started running, use --inspect-brk instead — useful if you suspect the issue is happening very early, during module resolution rather than during actual page rendering.
While connected this way, you can take a heap snapshot on demand rather than waiting for an automatic one. And if you're already running with --experimental-debug-memory-usage, there's a nice trick worth knowing: you can send the process a SIGUSR2 signal at any point, and it will take a heap snapshot right then, saved into your project root, ready to load into any heap analyzer.
# in one terminal
next build --experimental-debug-memory-usage
# in another terminal, find the PID and signal it
kill -SIGUSR2 <pid>
This combination — the debug flag plus manual signaling — is the closest thing Next.js gives you to "pause the build exactly when I think something's wrong and show me the heap." It's more hands-on than the automatic snapshotting, but it means you're not waiting for the process to hit its own memory ceiling before you get a look inside.
Let the Webpack Build Worker Isolate Compilation
Here's a fix that, in current Next.js versions, you might already be benefiting from without knowing it. The Webpack build worker runs your Webpack compilation inside a separate Node.js worker process rather than in the main build process. Isolating compilation this way measurably reduces the memory footprint of the overall build, because the worker process's memory is released back to the OS when it exits, rather than accumulating alongside everything else the main process is doing.
As of Next.js 14.1.0, this is enabled by default — but only if your project doesn't have a custom Webpack configuration. The moment you add a webpack() function to your next.config.js (to alias a module, tweak a loader, or anything else), Next.js can no longer assume it's safe to auto-enable the worker, and you have to opt in explicitly:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
webpackBuildWorker: true,
},
};
module.exports = nextConfig;
The practical implication: if you're on an older Next.js version, or you've added custom Webpack config for any reason (even something as small as a single alias), it's worth explicitly checking whether this flag is actually active in your build, rather than assuming it inherited the default. The docs also flag that it may not play nicely with every custom Webpack plugin — if you turn it on and start seeing plugin-related build errors that weren't there before, this is a reasonable first suspect to rule out.
Turn Off the Webpack Cache Where It's Not Earning Its Keep
Webpack's built-in cache stores compiled modules in memory and on disk between builds, specifically to make repeated builds faster. That's a genuinely good tradeoff on a developer's machine, where you're rebuilding the same project dozens of times a day and disk space is cheap. It's a much worse tradeoff on an ephemeral CI runner that builds your project exactly once before being torn down — there, the cache is pure memory overhead with no chance to pay for itself.
You disable it with a small addition to your Webpack config:
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (
config,
{ buildId, dev, isServer, defaultLoaders, nextRuntime, webpack },
) => {
if (config.cache && !dev) {
config.cache = Object.freeze({
type: "memory",
});
}
// Important: return the modified config
return config;
},
};
export default nextConfig;
Notice the !dev guard — this disables the cache specifically for production builds, while leaving your local development experience untouched. That distinction matters: you want the cache during next dev, where it's speeding up hot reloads you trigger constantly, and you want it gone during CI builds, where it's never getting the chance to be reused before the runner disappears. Getting this backwards (disabling it everywhere) will noticeably slow down your local dev loop for no memory benefit, since dev-server memory pressure and one-shot CI build memory pressure are different problems with different correct answers.
Turn Off TypeScript Checking During the Build — Carefully
Type checking a large TypeScript codebase is memory-hungry, sometimes disproportionately so compared to everything else happening in your build. If your production build is running out of memory specifically during the "Running TypeScript" step, and you already run type checking as a separate step in CI (which most reasonable pipelines do), you can tell Next.js to skip it during the build itself:
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
typescript: {
// !! WARN !!
// Dangerously allow production builds to successfully complete even if
// your project has type errors.
// !! WARN !!
ignoreBuildErrors: true,
},
};
export default nextConfig;
I want to be direct about the risk here, because the option name undersells it slightly: this doesn't just skip an optimization, it means next build will happily produce a deployable artifact even if your code has real, breaking type errors. That's fine — genuinely fine, not just tolerable — if you have a CI pipeline where a dedicated tsc --noEmit step runs before deployment and blocks the pipeline on failure. It's a real production risk if this flag is your only type-checking gate, because now nothing is stopping a type error from reaching production.
If you deploy on Vercel, they document a staging-and-promote workflow specifically for this situation — build and deploy to a staging environment first, run your checks, and only promote to production after they pass. If your platform doesn't support that pattern natively, you need some equivalent gate before this flag is safe to use, not just a hope that your team runs tsc before merging.
Turn Off Source Maps Where You Don't Need Them
Generating source maps is another meaningful memory cost during the build, and it's one that's easy to forget about because it's usually configured once and never revisited. Two separate settings control this for two different parts of your app:
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
productionBrowserSourceMaps: false,
experimental: {
serverSourceMaps: false,
},
};
export default nextConfig;
There's a third, more specific case worth knowing: Next.js generates source maps by default during the prerender phase of next build — the step where it's actually generating your static pages, which for large sites with many statically generated routes can be a real memory sink on its own. If your out-of-memory failures cluster specifically around the "Generating static pages" step rather than earlier in the build, there's a dedicated flag for that phase:
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
enablePrerenderSourceMaps: false,
};
export default nextConfig;
The obvious tradeoff: without source maps, a production error's stack trace points at minified, transformed code instead of your original source, which makes debugging production issues meaningfully harder. This is worth turning off during a memory-constrained CI build where you have no other option, but I'd think twice before disabling it as a permanent default if you have any other lever left to pull first — losing readable stack traces in production is a real cost you'll feel the next time something breaks in a way you can't reproduce locally.
Also worth flagging: some third-party plugins in your build pipeline turn source maps back on independently of these settings, and may need their own configuration to actually respect the setting you've made here. If you've set all of the above to false and are still seeing source maps generated, check whether a plugin you've added is re-enabling them.
A Historical Edge Runtime Fix
If you're running on an old, pinned Next.js version and you're seeing memory problems specifically tied to routes running on the Edge runtime, know that Next.js 14.1.3 shipped a fix for a real memory issue in that runtime. This isn't a configuration option — it's just a bug that existed and got fixed. If you're below that version and edge-specific memory issues are what you're chasing, updating is the actual fix, not a workaround.
Understand Preloading, Because It's Not a Bug
This last one isn't a memory problem so much as a memory behavior that's easy to misread as a leak if you don't know it's intentional.
When your Next.js server starts up, it preloads every page's JavaScript modules into memory upfront, rather than waiting to load each page's code lazily the first time it's actually requested. This is a deliberate tradeoff: it means the very first request to any given route doesn't pay a "cold module load" penalty, at the cost of a larger memory footprint from the moment the server boots, before a single request has even come in.
If you watch your server's memory usage right after a deploy and it climbs noticeably in the first few seconds with zero traffic, this is very likely why — and it's not evidence of a leak. You can turn the behavior off:
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: {
preloadEntriesOnStart: false,
},
};
export default config;
But here's the detail worth internalizing before you flip this: Next.js never unloads a page's modules once they've been loaded, preload or no preload. Turning this off doesn't cap your server's eventual memory usage — it only delays when each page's modules get loaded, spreading the same total memory cost out over the lifetime of the process instead of paying it all at startup. If your traffic pattern means most routes get hit within the first few minutes anyway, disabling this buys you very little; your memory usage converges to roughly the same steady-state number either way, just via a different curve. It's genuinely useful if you have a server with hundreds of routes where only a handful ever get real traffic and startup memory specifically (not steady-state memory) is what's constrained — think serverless cold-start environments with tight memory limits, where you'd rather pay the cost lazily per-route than all at once at boot.
A Practical Order of Operations
None of the tools above are things you should reach for all at once. When I'm actually debugging a memory issue, I go through roughly this sequence:
- Confirm it's actually a memory problem and not just a slow build — run with
--experimental-debug-memory-usagefirst, since it costs nothing and immediately tells you whether heap usage is climbing toward a real limit or your build is just doing a lot of legitimately slow work with modest memory. - Check the cheap structural wins first — is the Webpack build worker actually active? Is
webpackMemoryOptimizationson? Has anyone audited dependencies with the bundle analyzer recently? These cost nothing to try and often resolve the issue outright. - Only then reach for the CI-specific tradeoffs — disabling the Webpack cache in production builds, disabling prerender source maps — because these are genuinely fine in an ephemeral CI environment but come with real costs (slower local rebuilds, worse production stack traces) if applied indiscriminately.
- Treat
ignoreBuildErrorsas a last resort gated behind a real CI type-check step, never as a first move, because the failure mode when it goes wrong is silent, not loud. - If none of that resolves it, profile properly — a recorded
--heap-profsession or a live inspector attachment, loaded into Chrome DevTools, to find the actual allocation source rather than continuing to guess.
Key Takeaways
| Symptom | Tool / Setting | Tradeoff |
|---|---|---|
| Build uses a lot of memory generally | experimental.webpackMemoryOptimizations | Slightly slower compiles |
| Need visibility into where memory goes during a build | next build --experimental-debug-memory-usage | Incompatible with the Webpack build worker |
| Need a full recorded timeline of allocations | node --heap-prof ... next build | Produces a file you must load into DevTools separately |
| Want to inspect memory interactively | NODE_OPTIONS=--inspect (or --inspect-brk) | Manual, not automated |
| Compilation itself is memory-heavy | Webpack build worker (experimental.webpackBuildWorker) | Default off with custom Webpack config; some plugins incompatible |
| CI builds OOM but local builds are fine | Disable Webpack cache for !dev builds | Don't disable it for local dev too — you'll lose fast rebuilds |
| Build OOMs specifically during "Running TypeScript" | typescript.ignoreBuildErrors | Only safe with a separate CI type-check gate |
| Build OOMs during static page generation | enablePrerenderSourceMaps: false | Harder-to-read production stack traces |
| Server memory climbs immediately at startup | experimental.preloadEntriesOnStart: false | Doesn't lower eventual steady-state memory, only delays it |
Memory optimization in Next.js isn't a single switch — it's a toolbox of narrow, composable fixes, each aimed at a specific phase of the build or a specific runtime behavior. The right approach is almost never "turn everything on." It's diagnosing which phase is actually under pressure, using the profiling tools to confirm it rather than guess, and then reaching for the one or two settings that actually address that specific bottleneck.


