The Next.js build, not the app, is what runs the server out of memory
The short answer
On a small box the memory spike is the build, not the running app, and inside the build it is usually the TypeScript checking phase rather than bundling. Fix it by giving the build a hard memory ceiling of its own with systemd-run and MemoryMax, so the kernel kills the build when it goes over instead of letting the OOM killer pick a victim among everything else on the machine. Anything else sharing that server is what you are actually protecting.
A Next.js app that runs comfortably in a few hundred megabytes can still take a server down, and the part that does it is not the app. It is the build. We hit this twice on one memory-constrained EC2 box, hard enough both times that the machine needed a reboot from the AWS console rather than an SSH session, because there was no SSH session left to have.
The running app and the build are different workloads
The server holds one compiled application and serves it. The build holds the entire type graph of your project in memory while it checks it, then bundles. Those two numbers are not related, and sizing a box against the first one is how you end up surprised by the second.
Inside the build, the phase that peaks is usually TypeScript checking rather than bundling. That is worth knowing before you start tuning, because it points at a different set of fixes: checking types in CI instead of at build time, or splitting the project, rather than chasing bundler options that were never the problem.
The OOM killer does not have to pick your build
This is the part that turns a failed deploy into an outage. When Linux runs out of memory it chooses a process to kill by its own scoring, and nothing guarantees that choice is the build that caused the pressure. On a box with anything else on it, the victim can be the unrelated production service that was running perfectly well and was not being deployed.
So the real problem is not that the build needs too much memory. It is that the build's appetite is unbounded and shared with everything else on the machine.
Give the build a ceiling of its own
The fix is to run it in its own cgroup with a hard limit, which on any systemd host is one command rather than a new piece of infrastructure:
systemd-run --scope \ --property=MemoryMax=2G \ --property=MemorySwapMax=0 \ -- pnpm build
MemoryMax is the ceiling, and the kernel enforces it against this build alone.MemorySwapMax=0 matters as much: without it a runaway build spills into swap and takes the box down slowly instead of quickly, which is worse, because a machine thrashing on swap stops answering SSH while still looking alive to everything watching it.
With the ceiling in place the failure mode changes completely. The build dies, on its own, with a clear reason. That is an ordinary deploy failure you read in a log, rather than an incident you diagnose by rebooting a server and wondering what else stopped.
Then pick the number honestly
Set the ceiling below what would hurt the rest of the box, not at whatever number makes the build pass. If the build cannot finish inside a limit the machine can actually spare, the answer is a bigger machine for builds or a build that happens somewhere else, not a higher limit on a box that cannot back it. A ceiling you raise until it stops complaining is not a ceiling.
The related surprise: some variables are compiled in
One more thing worth knowing before you are debugging it at speed. Next.js bakes NEXT_PUBLIC_ environment variables into the compiled output at build time. They are not read when the server starts.
So the usual instinct, edit the environment file and restart the service, changes nothing for anything the browser sees, and it fails silently: no error, no warning, just the old value. Any change to one of those needs a full rebuild, which means the operation you were trying to avoid is the operation you now have to do, on the box you just discovered cannot always finish a build. Those two facts are much better learned in that order than in the other one.
Common questions
Why does my Next.js build use more memory than the app itself?
Because they are different workloads. The running server holds one compiled app; the build holds the whole type graph while it checks it, then bundles. Type checking is the phase that peaks, which is why builds that fail on a small box often succeed with checking moved out of the build step.
What happens when a build runs a Linux box out of memory?
The kernel's OOM killer picks a process by its own scoring, and there is no rule that it picks the build. On a shared box it can take out an unrelated production service, which turns a failed deploy into an outage of something that was not being deployed.
How do I stop a build from taking down other services?
Run it inside its own cgroup with a hard ceiling: systemd-run with MemoryMax, and MemorySwapMax so it cannot spill into swap and take the box down slowly instead. The build then fails on its own when it goes over, which is a normal deploy failure rather than an incident.
Why did my environment variable change not take effect after restarting?
Because NEXT_PUBLIC_ variables are compiled into the output at build time rather than read at runtime. Editing the environment and restarting changes nothing for anything the browser sees. That change needs a rebuild, which is a surprise precisely when you are trying to avoid one.
Keep reading
- Choosing a model for an in-app AI agent, and routing the turns that don't need it
Why prompt engineering cannot fix a routing problem, the asymmetric classifier, and the settings that silently stop applying after a model swap.
- How to test an AI agent that takes actions, without touching real data
Run it for real against development data, the five cases worth deliberately breaking, and why a timer on real execution backfires.
- Why your embedded script silently does nothing under a Content Security Policy
The failure that cannot report itself, the second directive everybody forgets, and why strict-dynamic makes your allowlist irrelevant.
Verb is this, built. An AI assistant you embed in your SaaS with one script tag: it calls your own API as the signed-in user, confirms before it changes anything, and logs every action. Free to build and test.