A field write-up on a wedged next-server: how a burst of requests plus strace catch a climbing RSS, how to tell a leak from ordinary caching by its futex/mmap/madvise signature, and why a heavyweight object built per request instead of once per process is the usual culprit. Plus why PM2 running npm start never sees any of it.
Updated: September 11, 2026.
A Next.js app on a single-core VPS runs fine for hours, sometimes days, then without warning pegs one core at 100% and stops answering. nginx starts throwing 499s and 504s, pm2 status still reports the process as alive, and the site simply does not load. Only pm2 restart fixes it, and a few hours later it happens again. This is not "the server is too weak", it is a memory leak: server code allocates something heavyweight on every request that never gets released, the heap grows, and at some point V8's garbage collector starts fighting that heap harder than it serves requests. Here is how to catch the leak with two plain tools instead of just cron-restarting and moving on.
In short. Fire 200-300 requests in a row and compare process RSS before and after. Normal caching grows and plateaus; a leak grows almost linearly and does not stop. During the next hang, capture
strace -p <PID> -cfor 10-15 seconds. If the output is dominated byfutex,mmap,munmapandmadviseinstead of the usualepoll_wait,readandwrite, that is V8's garbage collector fighting a bloated heap, not the server waiting on the network. The cause is almost always the same: server code (a page, a layout, middleware) builds an object on every request that should exist once per process, an i18n library initialized throughcreateInstance()/.use(), a fresh client, a new cache. The fix is moving that object to module scope. PM2'smax_memory_restartand a cron restart are stopgaps that hide the symptom rather than fix the code, and if PM2 runs the app vianpm start, it does not even see the real next-server hang in time to act.
[ СХЕМА 1 ]
The pattern is usually the same. The site opens fast, responds fine. A few hours pass, sometimes fewer, sometimes the box sits idle overnight and only tips over the next morning when real traffic arrives. Then top shows one core at 100%, the next-server process (or plain node, depending on how it starts) at the top of the CPU list, and new requests either hang and time out or nginx returns them straight away: 504 Gateway Timeout if the client waited for the upstream, 499 if the client gave up first. Meanwhile pm2 status reports online honestly enough; the process is alive, it just cannot keep up. Only pm2 restart fixes it. A few minutes later everything is fast again, and the whole cycle repeats.
That is a different animal from a VPS being generally slow because of oversubscription, a noisy neighbor, or a full disk. General "the server is slow" triage is its own topic, covered in a separate Linux diagnostics article. Here the box's average load is low, but one specific process keeps growing in memory on its own, regardless of how much real traffic the site sees. Leave it alone and it does not recover; the leak does not drain by itself. The heap keeps growing until the garbage collector starts burning an entire core just trying to deal with it.
The first tool is the simplest one: put a small load on the process and watch what happens to memory. RSS (resident set size) is the amount of physical memory the process actually occupies right now, unlike virtual address space, which says almost nothing about real usage.
Check the starting value, fire a burst of requests, then check again:
ps -o rss,cmd -C next-server
for i in $(seq 1 240); do curl -s -o /dev/null localhost:3000/; done
ps -o rss,cmd -C next-server
ps -o rss,cmd -C next-server prints RSS in kilobytes and the command line for every process named next-server. On the live test for this article (Next 16.3.4), the process really was named next-server (v16.3.4), no edits needed. Under output: 'standalone' the name can differ; if nothing shows up, search with ps aux | grep next and substitute the real name or PID.for i in $(seq 1 240); do curl -s -o /dev/null localhost:3000/; done fires 240 requests at the homepage in a row, discards the response body (-o /dev/null) and silences curl's own output (-s). 240 is a rough guideline: what matters is sustained load over a few minutes, not the exact count. If the site needs auth, or has a heavier route that is more likely to hold the leak (an i18n-driven page, a form, an API route), hit that one instead.If watching memory through PM2 is more convenient than ps, pm2 jlist dumps the full process list as JSON, including a monit object with current memory and CPU:
pm2 jlist | grep -A2 '"memory"'
What you should see: two RSS numbers, before and after. A healthy process usually ends up somewhat higher after the burst than at idle. Next.js caches compiled pages and data, so some growth is expected, but the growth stops and flattens out after the first few dozen requests. A leaking process keeps climbing for most of the burst and shows no sign of settling. Here is what that looked like on the live test for this article, a test route with a deliberate leak: a per-request Buffer.alloc(2MB) pushed into a module-level array and never cleared, the same bug shape covered below.
Requests from start | RSS | Growth over that stretch |
|---|---|---|
0 (baseline) | 122,228 KB (~119 MB) | - |
240 | 161,232 KB (~157 MB) | ~163 KB/request |
480 | 207,440 KB (~203 MB) | ~193 KB/request |
720 | 281,168 KB (~275 MB) | ~307 KB/request, the rate itself accelerates as GC comes under more pressure |
~3,700 total | 959,724 KB (~937 MB) | - |
The climb is monotonic with no plateau anywhere along the way, not at request 240 and not at request 3,700. That is the diagnostic signal itself: a healthy process that is only warming a cache climbs over the first few dozen requests and then holds roughly steady no matter how much more load you throw at it.
CPU load climbs alongside RSS. On the same test box (2 vCPU / 4 GB), top during the active burst showed next-server at 81.8% CPU on a single core, single-threaded and CPU-bound work, the second core barely involved, with RES ranging 296-710 MB depending on the moment sampled. That is the "one core pinned" picture from the top of this article, caught mid-leak rather than at a full stall.
A single run is not always conclusive, especially if the process had a chance to warm something up in the background between tests. Repeat the burst two or three times back to back without restarting the process in between. If every pass adds more memory instead of hovering near one value, that is a leak, not a one-time cache warm-up.
The load test is something you can trigger yourself, any time. A real hang is a rare, unplanned event, and when it happens, the useful move is to attach strace to the process: a tool that shows which system calls (a process's requests to the Linux kernel) a process is making and how much time it spends on each.
Find the PID of the stuck process and capture a call summary for 10-15 seconds, then stop with Ctrl+C:
pgrep -f next-server
sudo strace -p <PID> -c
pgrep -f next-server looks for a PID by matching a substring against the full command line, more reliable than the short comm name, which Linux truncates to 15 characters.strace -p <PID> attaches to a process already running rather than starting a new one. -c skips the line-by-line trace and instead accumulates a summary table, printed on Ctrl+C or when the process exits: how much time and how many calls per syscall.strace almost always needs sudo, even for a process you own; it depends on the kernel's ptrace_scope setting (see the "to verify" item in the brief).If you cannot catch the whole hang with a summary run, a shorter capture filtered to memory calls also tells you enough:
sudo strace -f -e trace=memory -p <PID> -o /tmp/strace-memory.log &
sleep 10
sudo kill %1
-f follows child threads and processes too, which matters for Node: V8 runs garbage collection and other work on extra threads.-e trace=memory narrows the capture to memory-related calls (mmap, munmap, brk, madvise and similar) instead of logging everything.-o /tmp/strace-memory.log writes to a file rather than the terminal, easier to read once the site is back.What you should see on a normal, non-stuck process under load: near the top of the list, epoll_wait (the process waiting for socket events), plus read and write (reading the request, writing the response). It waits on the network because that is exactly what the async model in Node.js is built on.
Per V8's documented behavior, a process fully wedged by a leak should show output dominated by futex (a thread-synchronization syscall, heavily used by V8's internal threads, including the garbage collector, during long pauses), plus mmap, munmap and madvise (calls the process uses to request new memory pages from the kernel, release them, or tell the kernel how to treat them), with epoll_wait nearly gone from the summary. The live test for this article did not reach that state, but the trend toward it is confirmed with real numbers: at RSS around 280 MB, strace -p <PID> -c over 6-8 seconds of active load showed futex at about 9% of time; once the leak had accumulated further and RSS reached 600-950 MB, that share rose to 15%. write, epoll_pwait, writev and read were still near the top of the output the whole time, legitimate network I/O under load, not the stall itself. What is confirmed is the climbing futex share as the leak grows, not a final snapshot of a fully wedged process; that state was not reached in this run.
Signal | Normal next-server under load | next-server wedged by a leak |
|---|---|---|
Dominant calls in |
| expected - |
What it means | waiting on socket events, reading requests, writing responses, ordinary event-loop work | V8's threads (including GC) synchronizing and reshaping memory for a runaway heap |
CPU at that time | fluctuates, idle gaps between requests | trending toward 100% on one core, idle time shrinking |
What to do next | nothing, this is normal | look in the code for an object built fresh on every request instead of as a singleton |
The right-hand column describes the expected end state per V8's documented model. The live test confirmed the futex share climbing from 9% to 15% as the leak grew and RSS ran from ~280 MB to ~950 MB; full domination by futex plus mmap/munmap/madvise with epoll_wait nearly gone was not reached in the time available; the test box had limited RAM and no swap, so it simply ran out of runway before a true stall. If your production process has gone all the way to unresponsive, expect that end-state picture. If you are capturing strace on a process that still answers but is clearly straining, watch for a rising, not necessarily final, futex share.
In the large majority of SSR memory leaks in Next.js, the cause is the same: somewhere in server code, a page, a layout, middleware or an API route, a constructor or factory runs that should execute once per process instead of once per request. The classic example is initializing a localization library in the i18next style: it builds internal event listeners, translation caches and subscriptions on construction, and it is designed to live as one long-lived instance.
Here is the shape of the bug, simplified; the real library's API may differ in the details, the underlying problem does not:
// app/[locale]/layout.tsx - BAD: a new instance on every request
import i18nLib from 'some-i18n-lib';
export default async function LocaleLayout({ params, children }) {
const i18n = i18nLib.createInstance();
await i18n.use(reactBinding).init({
lng: params.locale,
resources: translations,
});
return <Provider i18n={i18n}>{children}</Provider>;
}
Every time this layout renders, which is to say on every request, createInstance() allocates a new object, and .use(...).init(...) registers internal listeners on it. The instance from the previous request has not gone anywhere: the subscriptions and timers the library created during its own initialization still reference it, so the garbage collector cannot reclaim it. Every subsequent request adds one more such object to the heap, and the heap keeps growing until there is too much of it for one core to handle.
The fix is to build the object once per process and reuse it, varying only what actually changes between requests, such as locale:
// lib/i18n.ts - module scope, runs once when the process starts
import i18nLib from 'some-i18n-lib';
const instances = new Map();
export function getI18n(locale) {
if (!instances.has(locale)) {
const i18n = i18nLib.createInstance();
i18n.use(reactBinding).init({ lng: locale, resources: translations });
instances.set(locale, i18n);
}
return instances.get(locale);
}
const instances = new Map() is declared at the top level of the module, not inside a component function. A Node.js module loads once per process, so the Map lives for the process's entire runtime.getI18n(locale) only builds a new instance if that locale has never been seen before, then hands back the stored one every time. Most sites have a handful of locales (2-5), so the heap ends up with a few of these objects, not thousands.layout.tsx, the call becomes const i18n = getI18n(params.locale), with no await-ed initialization on every request, since initialization already happened earlier.The same pattern shows up without any i18n library involved: new PrismaClient() inside a route handler instead of a module, a fresh HTTP client with its own connection pool on every API route call, a new Redis or S3 client inside middleware. The rule is the same across all of them: if an object does not depend on the specific request, or depends only on a small, bounded set of values like locale, build it once in module scope, not fresh on every call.
There is a separate trap that makes the leak even harder to spot: how PM2 actually starts the process. If ecosystem.config.js sets script to npm and args to start, PM2 supervises the thin npm wrapper, not the real next-server. That wrapper does almost nothing and sits at near-zero CPU with stable memory. The actual next-server runs as its child process, and when that child leaks or hangs, PM2 never sees it, because it is watching the wrong thing entirely.
max_memory_restart in that setup measures the npm wrapper's memory, not the app's, and never fires in time: the wrapper idles at a few dozen megabytes while the child next-server inside it climbs toward the server's actual memory ceiling. The fix is pointing script at the absolute path to the Next binary itself and running it directly, with no npm layer in between. This is covered in detail in the first article of the series, deploying Next.js on a VPS. If your config currently runs npm start, fix that first; only after that will PM2's metrics reflect reality at all.
PM2's max_memory_restart is a guardrail, not a cure. It restarts the process once RSS crosses a set threshold and keeps a leak from running all the way to an OOM kill (the kernel terminating processes when the whole system runs out of memory). That is useful as a last line of defense, but if there is a real leak, the limit keeps firing again and again, on a more predictable schedule than before, not less often.
A scheduled cron restart is the same stopgap in different words: the site stops staying down for long, but the leak is still there and the cause stays unfound. Worse, a well-tuned cron restart can hide the fact that a leak exists at all; if the interval has enough margin, months can pass before heavier traffic tips the process over faster than the schedule catches it.
The real fix is exactly one thing: find the place in the code where a heavyweight object is built on every request when it should exist once per process, and move its construction to module scope as shown above. After that, RSS under the burst test should behave like a healthy process, climbing on the first requests and then flattening out, and max_memory_restart plus a cron restart go back to being what they were meant to be: insurance for a rare edge case, not a working mechanism.
next start invoked directly, not through an npm/yarn wrapper? Check the script field in ecosystem.config.js, or the equivalent systemd unit. Without this, PM2 and any monitoring measure the wrong process.strace during a stall show futex/mmap/munmap/madvise dominating over the usual epoll_wait/read/write?.use(...), createInstance() or similarly heavy initializer sitting directly inside a page, a layout, or middleware, that is, in a code path that runs on every request?new SomeClient() (a database client, an HTTP client, a cache) inside a route handler instead of creating it once at the top of a module?With two simple tools: a burst of requests with RSS sampled before and after, and strace -p <PID> -c captured during an actual hang. If RSS climbs almost linearly with no plateau, and strace is dominated by futex/mmap/munmap/madvise, you have a leak, not just a server that is short on resources.
Almost always because server code builds an object on every request that should exist once for the whole process. A common example is initializing a localization library through createInstance()/.use() inside a page or layout. Every call registers new internal listeners that never get released, and the V8 heap grows with each request, without bound.
It fires after the leak has already happened, so it treats the symptom, not the cause, and just moves the hang onto a more predictable schedule. It gets worse if PM2 runs the app via npm start: then it supervises the npm wrapper instead of the real next-server, and the memory limit measures the wrong process entirely.
The same approach works outside Next.js too: a load test with RSS sampled before and after shows whether a leak exists at all, and strace -c during a stall shows whether the process is fighting for memory or simply waiting on network or disk. To pin the exact spot in the code, take a heap snapshot with node --inspect and Chrome DevTools' Memory tab, or use a profiler such as clinic.js.
Usually it is not useful work but V8's garbage collector, scanning a bloated heap over and over looking for memory to reclaim, and never keeping up because the leak adds memory faster than the collector can free it. On a single core, that same core also has to handle requests, so the event loop, the mechanism Node.js uses to process tasks one at a time on a single thread, effectively stalls, and the site stops responding.
futex is a thread-synchronization syscall; in Node.js, V8's internal threads, including the garbage collector, generate it heavily during long pauses. mmap, munmap and madvise are the calls a process uses to request new memory pages from the kernel, release them, or advise the kernel on how to handle them. When those dominate a strace -c summary and ordinary network calls like read, write and epoll_wait are nearly absent, the process is busy fighting for memory, not serving requests.
ps -o rss,cmd -C next-server before and after. Caching grows and plateaus; a leak grows almost linearly and does not stop: on the live test RSS ran 119 MB → 157 MB → 203 MB → 275 MB → 937 MB with no plateau and an accelerating per-request growth rate.strace -p <PID> -c during a hang. A rising futex share (9% → 15% as the leak accumulated, in the live test) is the confirmed signal; full domination by futex/mmap/munmap/madvise over epoll_wait/read/write is the expected picture at a full stall per V8's model, worth checking against your own process rather than assuming as a guaranteed snapshot.Map keyed by whatever actually varies (locale, for example), and stop rebuilding it on every render.npm start instead of a direct path to the Next binary, it supervises the wrapper, not the app, and sees neither the hang nor the leak.max_memory_restart and a cron restart are last-resort insurance, not a fix. They hide the symptom and burn time that is better spent finding the actual cause.