Memory leaks aren't the only way a Next.js server dies in production. Several October 2026 advisories show that a single crafted request can do the damage. One freezes the Node process by blocking the event loop. Another gives unauthenticated remote code execution. Patch those first. Then use the debugging steps below to find what your server was holding when it stalled, and to check whether anything else in your code can be pushed the same way.
The debugging steps are the same whether the cause is a leak or an attack: capture the process state while it's bad, find the object or code path that holds the resources, then fix it. Every code block here was run on Node.js v22.22.2 while writing this guide.
What you need: a Node.js service you can deploy, shell access to the server (or container), and enough free disk for a few heap snapshots. A snapshot is about the size of your heap, so a 150MB heap means roughly 150MB of disk per snapshot.
Before You Start: Patch the Known Next.js Advisories
Check your installed version, then move to the patched release. Next.js published these fixes on October 14, 2026:
npm ls next # see what is installed
npm install next@16.4.1 # Active LTS line
npm install next@15.5.28 # Maintenance LTS lineThe October 14 release addresses three issues, according to the Next.js security update:
- Unauthenticated RCE in
use cachefunctions that return multiple typed arrays (GHSA-673q-mjm6-r5xr). The Next.js post says there is no workaround, so upgrade even if you don't think you have such functions. - Unauthenticated RCE in the Image Optimization API when using SVG (CVE-2026-96889), on glibc-based Linux. The workaround is
sharp0.35.5 or later, but upgrading is simpler. - Denial of service in App Router Server Actions (GHSA-p3m5-59mp-jv5r). Crafted requests drive excessive CPU use that blocks other requests in the same process. Pages Router apps and apps without Server Actions are not affected. An earlier advisory, GHSA-8h8q-6873-q5fj, covers a similar CPU denial of service in Server Function endpoints. It is also tracked as CVE-2026-23870 through its upstream React advisory. It has a CVSS score of 7.5 and requires no authentication. Its fixed versions are 15.5.16 and 16.2.5, so the October patches cover it too.
Also check your lockfile. Running npm ls next shows the resolved version, which may be older than your package.json suggests.
Step 1: Confirm It's a Leak, Not Normal Growth
Not every rise in memory is a leak. Caches warm up, and Node's garbage collector frees memory in sawtooth patterns. A leak shows up as a steady climb that never falls back, even after load drops.
Add a sampler that logs heap statistics every minute. Use v8.getHeapStatistics() rather than process.memoryUsage() alone, because it reports the heap limit V8 is actually enforcing.
const v8 = require('node:v8');
setInterval(() => {
const { used_heap_size, heap_size_limit } = v8.getHeapStatistics();
console.log(JSON.stringify({
event: 'mem_sample',
heapUsedMB: +(used_heap_size / 1048576).toFixed(1),
heapLimitMB: +(heap_size_limit / 1048576).toFixed(1),
ratio: +(used_heap_size / heap_size_limit).toFixed(3),
}));
}, 60_000).unref();Let it run for a day. Read the log as a trend, not a snapshot:
- Sawtooth (rises, drops after garbage collection, rises again): healthy.
- Staircase (each low point is higher than the last): probable leak. If the heap is flat but requests stall, it's a CPU freeze, not a leak. A CPU-bound request handler blocks the event loop without allocating memory, and the heap graph won't show it. Watch event-loop delay instead:
const { monitorEventLoopDelay } = require('node:perf_hooks');
const loop = monitorEventLoopDelay({ resolution: 20 });
loop.enable();
setInterval(() => {
console.log(JSON.stringify({
event: 'loop_delay',
p99ms: Math.round(loop.percentile(99) / 1e6),
maxMs: Math.round(loop.max / 1e6),
}));
loop.reset();
}, 1000).unref();In our test, a handler that blocked for 600ms showed up as a maxMs of 602 while the heap stayed at 4.6MB. When the log shows spikes like that, capture a CPU profile of the process during an incident. Start Node with --cpu-prof --cpu-prof-dir=./profiles. That writes a .cpuprofile file when the process exits cleanly. We confirmed the file is created and parses as JSON. Opening it in Chrome DevTools is a standard workflow we did not run here, so check it in your DevTools version. For a live server, prefer a diagnostic signal or a short-lived profiling build over restarting it blindly.
Note that heap_size_limit may not match your --max-old-space-size value. In our test, we passed --max-old-space-size=120, and V8 reported a 168MB limit. Always compare against the number V8 reports.
Step 2: Reproduce It Locally With a Known Leak
Before you hunt a real leak, practise on a known one. This service has the bug most founders ship at some point: a cache keyed by a value that changes on every request, so entries never get reused and never get evicted.
// leak.js: simulated production leak
const cache = new Map();
let requests = 0;
function handleRequest(userId) {
requests++;
// BUG: the key includes a per-request value, so nothing is ever evicted.
const key = `${userId}:${Date.now()}:${requests}`;
cache.set(key, { payload: Buffer.alloc(2048, 'x'), at: Date.now() });
}
setInterval(() => {
for (let i = 0; i < 500; i++) handleRequest(i % 50);
}, 10);
setInterval(() => {
const mem = process.memoryUsage();
console.log(JSON.stringify({
heapUsedMB: +(mem.heapUsed / 1048576).toFixed(1),
rssMB: +(mem.rss / 1048576).toFixed(1),
cacheEntries: cache.size,
}));
}, 1000);Run it with a memory cap so it fails fast:
node --max-old-space-size=120 leak.jsIn our run, heap climbed from 24MB to 95MB in five seconds, and the process crashed with FATAL ERROR: Ineffective mark-compacts near heap limit. A real leak is slower, but the shape is identical.
Step 3: Add a Safe Watchdog to Production
Restarting the process destroys the evidence, so the watchdog must capture a heap snapshot while the leak is still present. It needs three guards, or it will fill your disk:
- Edge trigger: snapshot once when usage crosses the threshold, then stay disarmed until usage falls back. A watchdog that snapshots every few seconds while the heap stays high will write dozens of files.
- Hard cap: a maximum number of snapshots per process lifetime.
- Separate trigger and reset ratios: for example, trigger at 80% and re-arm at 60%, so it doesn't flap around one value.
// memwatch.js: production memory watchdog
const fs = require('node:fs');
const v8 = require('node:v8');
const path = require('node:path');
const DIR = process.env.SNAPSHOT_DIR || './snapshots';
const SAMPLE_MS = Number(process.env.MEM_SAMPLE_MS || 60_000);
const TRIGGER_RATIO = Number(process.env.MEM_TRIGGER_RATIO || 0.8);
const RESET_RATIO = Number(process.env.MEM_RESET_RATIO || 0.6);
const MAX_SNAPSHOTS = Number(process.env.MEM_MAX_SNAPSHOTS || 3);
let armed = true;
let snapshotsTaken = 0;
let snapshotting = false;
fs.mkdirSync(DIR, { recursive: true });
function sample() {
const { used_heap_size, heap_size_limit } = v8.getHeapStatistics();
const ratio = used_heap_size / heap_size_limit;
console.log(JSON.stringify({
event: 'mem_sample',
heapUsedMB: +(used_heap_size / 1048576).toFixed(1),
heapLimitMB: +(heap_size_limit / 1048576).toFixed(1),
ratio: +ratio.toFixed(3),
}));
if (ratio < RESET_RATIO) armed = true;
if (armed && ratio >= TRIGGER_RATIO && !snapshotting && snapshotsTaken < MAX_SNAPSHOTS) {
armed = false;
snapshotting = true;
const file = path.join(DIR, `heap-${Date.now()}.heapsnapshot`);
try {
v8.writeHeapSnapshot(file); // synchronous: blocks the event loop
snapshotsTaken++;
console.log(JSON.stringify({ event: 'snapshot_written', file, total: snapshotsTaken }));
} catch (err) {
console.error(JSON.stringify({ event: 'snapshot_failed', error: err.message }));
} finally {
snapshotting = false;
}
}
}
const timer = setInterval(sample, SAMPLE_MS);
timer.unref(); // monitoring alone shouldn't keep the process alive
module.exports = { sample };Load it at the top of your entry file:
require('./memwatch');
// ...rest of your appKnow the cost before you trigger it. v8.writeHeapSnapshot() is synchronous, blocks the event loop for the whole write, and needs roughly twice the heap in free memory. In our test, RSS sat around 545MB at an 80MB heap, then reached about 1.5GB while a 90MB heap was being snapshotted. Set the trigger well below the limit, and make sure the container has headroom above your normal peak. Don't snapshot a process that's serving traffic during peak load if you can avoid it.
Prefer a signal for manual captures. When you're already on the box and want a snapshot on demand, start Node with a signal flag. We tested this on Node 22:
node --heapsnapshot-signal=SIGUSR2 --diagnostic-dir=./snapshots server.js
kill -USR2 <pid>Send the signal to the Node process itself, not to a wrapper like timeout or npm, or the signal won't reach Node.
Step 4: Capture Two Snapshots and Compare Them
One snapshot shows what's in memory. Two snapshots, taken a while apart, show what grew. The watchdog gives you a first snapshot when the threshold trips. Take a second one later, under similar load, with the same flags. The heap should be clearly larger in the second file. If it isn't, you probably captured a normal GC cycle, not a leak.
Open both files in Chrome DevTools:
- Open DevTools on a Chromium browser and go to the Memory tab.
- Load the earlier snapshot. The Chrome DevTools docs describe taking snapshots in the panel but not loading saved files, so check how your DevTools version loads a
.heapsnapshotfile. - Load the later snapshot, then open the view drop-down at the top of the panel and select Comparison. Set the comparison baseline to the earlier snapshot.
- Expand the totals. The docs say the view shows added and deleted object instances, and that you confirm a leak by inspecting the delta in freed memory and reference count. Look for constructor names that keep growing:
Map,Array,Object,Buffer, or your own class names. Expand the largest entry to see what holds the references. If the top entries are(string)or(compiled code), the leak is probably elsewhere. Look for growingMaporArrayobjects, then check their retainers.
Note: this DevTools step was not run in the sandbox. The docs confirm the Comparison view and its baseline setting, but not the column labels or the file-loading control, so verify those in your browser version.
Step 5: Fix the Leak at the Source
The snapshot points at a growing container. In the reproduction, the Map named cache holds 230,000 entries after five seconds, each referenced by a unique key. The fix has two parts: use a stable key, and bound the size.
// fixed.js: bounded cache with a stable key
const MAX_ENTRIES = 1000;
const cache = new Map();
function setBounded(key, value) {
if (cache.has(key)) cache.delete(key); // refresh insertion order
cache.set(key, value);
if (cache.size > MAX_ENTRIES) {
cache.delete(cache.keys().next().value); // evict oldest
}
}
function handleRequest(userId) {
setBounded(`user:${userId}`, { payload: Buffer.alloc(2048, 'x'), at: Date.now() });
}
module.exports = { handleRequest, cache };Make the key depend only on the data you actually want to reuse. A key containing Date.now() or a request counter guarantees that nothing is ever reused. Also check for the other common sources of unbounded growth:
- Unbounded arrays:
logs.push(...)with no trimming. - Event listeners:
emitter.on(...)inside a request handler without a matchingoff(...). Node warns withMaxListenersExceededWarningwhen this happens, so grep your logs for it. - Closures held by timers or long-lived objects: a
setIntervalcreated per request that keeps the request object alive.
Step 6: Verify the Fix Under Load
A fix you haven't measured is a guess. Run the fixed version under the same load and cap as the broken one:
node --max-old-space-size=120 fixed.jsOur run for seven seconds:
{"heapUsedMB":3.7,"rssMB":55.1,"cacheEntries":50}
{"heapUsedMB":4.4,"rssMB":56.6,"cacheEntries":50}
{"heapUsedMB":5,"rssMB":63.1,"cacheEntries":50}
{"heapUsedMB":4.9,"rssMB":63.5,"cacheEntries":50}
{"heapUsedMB":4.7,"rssMB":63.5,"cacheEntries":50}Heap stayed between 3.7MB and 5MB while the broken version climbed from 24MB to 95MB. The cache size is pinned at 50 because the key is stable.
Then ship a regression check so the leak can't return quietly. A unit test that calls the handler more times than the cap and asserts the cache size is enough. Move the cache and handler into a module that exports them, then:
// test/cache.test.js (adapt the require path to your module)
const { handleRequest, cache } = require('../fixed');
for (let i = 0; i < 10_000; i++) handleRequest(i);
if (cache.size > 1000) throw new Error(`cache grew to ${cache.size}`);Step 7: Add Guardrails for Production
A fix stops the bleeding. Guardrails catch the next leak before it crashes you at 3am.
- Alert on the heap ratio. Send an alert when
ratiostays above 0.8 for 10 minutes. Use the samemem_samplelog line from Step 1, shipped to your log tool. - Set a container memory limit with headroom. A limit at the exact heap size leaves no room for a snapshot or for normal spikes.
- Use a process manager restart as a backstop. If you run PM2, its
max_memory_restartoption (for examplemax_memory_restart: '300M'inecosystem.config.js) restarts a process that exceeds a set memory size. PM2 checks memory about every 30 seconds, so the restart can lag the threshold. Treat it as a safety net, not a fix. A restart hides the leak and loses in-memory state. - Watch the trend, not the peak. A single high reading is noise. A rising baseline across deploys is a leak.
Common Mistakes to Avoid
Snapshotting the only instance. If a snapshot doubles memory on your only server, it can take the service down. Snapshot from a replica or take the instance out of the load balancer first.
Trusting process.memoryUsage() alone. rss includes memory outside the JavaScript heap, such as Buffers and native modules. Use heapUsed against heap_size_limit to judge the JavaScript heap.
Snapshotting too late. At 99% of the limit, the snapshot itself can push the process into an out-of-memory crash. Trigger at 70 to 80 percent.
Ignoring the cache semantics. Bounding a cache changes hit rates. Measure the hit rate after the fix, or you'll trade a memory problem for a database load problem.
Restarting without capturing evidence. A restart is fast and feels like a fix. You'll see the same problem again in a few days, with no snapshot to diagnose it.
Results From Our Reproduction
| Metric | Broken (leak.js) |
Fixed (fixed.js) |
|---|---|---|
| Heap over the test window | 24MB to 95MB in 5 seconds, climbing | 3.7MB to 5MB over 7 seconds, flat |
| Entries in cache | 232,500 and rising | 50 (stable) |
| Outcome under 120MB cap | Crashed, OOM | Stable for 7 seconds |
| Snapshot size | ~140MB at ~90MB heap | Not needed |
These numbers come from a synthetic leak on a laptop-class sandbox. Your real numbers will differ. What should carry over is the shape: a monotonic climb, a snapshot that points at one container, and a flat line after the fix.
Sources and Further Reading
- Node.js
v8module documentation:writeHeapSnapshot()andgetHeapSnapshot()behaviour, including the synchronous blocking and the roughly 2x memory cost. Verified against the official page. - Node.js Command-line API: reference for
--max-old-space-sizeand the diagnostic flags. We tested--heapsnapshot-signalon Node 22, but the flag's full description was not in the page text we retrieved, so check it against your Node version. - Next.js Security Update, October 14, 2026: the official list of fixes and patched versions used in the patch section.
- GHSA-8h8q-6873-q5fj on OSV: the earlier Server Function DoS advisory, with affected and fixed versions and CVSS.
- CVE-2026-94545 analysis (SOC Prime): a third-party write-up of an SVG
ImageResponseRCE affecting Next.js 16.2.0 through 16.3.5. The October 14 Next.js post does not list this ID, so confirm it against the advisory before citing it. - Chrome DevTools: Heap snapshots: the Comparison view and baseline setting described in Step 4.
- PM2: Memory limit:
max_memory_restartsyntax, units, and the 30-second check interval. - Debugging Memory Leaks in Node.js: A Walkthrough: an external walkthrough of the same comparison workflow. Read it as a cross-check.
Got stuck, or want this shipped end-to-end for you? bitroot.club builds custom products for founders. →