Node.js PM2 Service Infinite Restart Loop Due to Memory Leak Troubleshooting Guide
Resolve Node.js PM2 application infinite restart loops caused by memory leaks. Learn to diagnose, profile, and fix memory issues for stable production deployments.
Resolve Node.js PM2 application infinite restart loops caused by memory leaks. Learn to diagnose, profile, and fix memory issues for stable production deployments.
A Node.js application managed by PM2 that enters an infinite restart loop, often accompanied by high memory consumption, is a classic symptom of a memory leak. This guide provides a comprehensive, expert-level approach to diagnose, debug, and resolve such critical production issues, ensuring your services remain stable and performant.
Symptom & Error Signature
Users will typically experience intermittent or complete unresponsiveness from the web application. From a systems perspective, you'll observe the PM2 process for your Node.js application constantly restarting.
Typical Observations:
PM2
statusorlistshowing highrestartscount:pm2 list┌────┬────────────────────┬───────────┬──────┬─────────┬─────────┬───────────┬──────────┬───────────┬──────────┬──────────┬───────────────────┐ │ id │ name │ namespace │ version │ mode │ pid │ uptime │ restart │ status │ cpu │ memory │ user │ ├────┼────────────────────┼───────────┼──────┼─────────┼─────────┼───────────┼──────────┼───────────┼──────────┼──────────┼───────────────────┤ │ 0 │ my-node-app │ default │ 1.0.0 │ fork │ 23456 │ 0s │ 127 │ errored │ 0% │ 23.4 MB │ nodeuser │ │ 1 │ my-node-app │ default │ 1.0.0 │ fork │ 23457 │ 1s │ 126 │ online │ 100% │ 1.8 GB │ nodeuser │ └────┴────────────────────┴───────────┴──────┴─────────┴─────────┴───────────┴──────────┼──────────┼──────────┼──────────┼───────────────────┘ │ ^^^ │ ^^^ │ │ High │ High & │ │ Restarts │ Growing │PM2
logsindicating frequent restarts, potentially due toSIGINTorSIGKILL:pm2 logs my-node-app --lines 500|my-node-app | [2026-06-30T14:30:01.123Z] INFO: Server started on port 3000 0|my-node-app | [2026-06-30T14:30:05.456Z] WARN: Memory usage: 1.5GB 0|my-node-app | [2026-06-30T14:30:10.789Z] WARN: Memory usage: 1.7GB PM2 | App [my-node-app:0] exited with code [0] via signal [SIGINT] PM2 | Starting application my-node-app in development mode... PM2 | App [my-node-app:0] online 0|my-node-app | [2026-06-30T14:30:11.234Z] INFO: Server started on port 3000 0|my-node-app | [2026-06-30T14:30:15.567Z] WARN: Memory usage: 1.5GB 0|my-node-app | [2026-06-30T14:30:20.890Z] WARN: Memory usage: 1.7GB PM2 | App [my-node-app:0] exited with code [0] via signal [SIGINT] PM2 | Starting application my-node-app in development mode... PM2 | App [my-node-app:0] online ... (this pattern repeats indefinitely)The
SIGINToften implies PM2'smax_memory_restartlimit was hit, triggering a graceful restart. If it'sSIGKILL, the process might have been forcefully terminated by the kernel's Out-Of-Memory (OOM) killer.High system memory utilization via
top/htop:htopYou'll see one or more
nodeprocesses consuming a disproportionately large and often continuously increasing amount of RAM.dmesgoutput showing OOM Killer activation:dmesg | grep -i "oom-killer"[ 12345.678901] Killed process 23457 (node) total-vm:2100000kB, anon-rss:1800000kB, file-rss:0kB, shmem-rss:0kBThis indicates the Linux kernel terminated the Node.js process due to critically low available memory.
Root Cause Analysis
An infinite restart loop due to a memory leak in a Node.js application managed by PM2 is fundamentally caused by the application continuously consuming more memory without releasing it. This eventually triggers either PM2's configured max_memory_restart limit or the operating system's OOM killer, leading to a restart.
The underlying reasons for memory leaks in Node.js (V8 engine) applications typically include:
- Unbounded Caches or Data Structures: Storing data in global objects, arrays, or maps without proper eviction policies. Each request or event adds more data, which is never cleared.
- Unreleased Event Listeners: Event emitters (e.g.,
EventEmitter, streams, HTTP servers) can retain references to listeners. If listeners are added repeatedly withoutremoveListener()being called, especially on short-lived objects, they can accumulate and prevent garbage collection of the entire scope they enclose. - Closures Retaining Large Scopes: Variables captured by closures can prevent the garbage collection of larger objects in their parent scope, even if those objects are no longer directly used.
- Improper Stream Handling: Forgetting to
drain(),destroy(), or properly close streams (e.g., file streams, network streams) can lead to buffer accumulation. - Global Variables: Assigning large objects or data to global variables (
globalor module-scoped variables) and never nullifying them. - Third-Party Library Issues: Sometimes, the leak might originate from an external dependency that itself has a memory management bug.
- PM2
max_memory_restartTrigger: While not a root cause of the leak, a poorly configuredmax_memory_restartvalue can exacerbate the restart loop frequency or mask the underlying problem by restarting the application before other symptoms are obvious. PM2's primary purpose here is to mitigate the impact of a failing process, not to fix the leak itself.
Step-by-Step Resolution
Addressing a Node.js memory leak requires a methodical approach, moving from observation to deep code analysis.
1. Initial Diagnosis & PM2 Configuration Review
Start by verifying the PM2 setup and current state.
Check PM2 Application Status:
pm2 list pm2 describe my-node-appNote the
restartscount,memoryusage, and the configuredmax_memory_restartvalue frompm2 describe.Review PM2 Logs:
pm2 logs my-node-app --lines 100 --timestampLook for patterns indicating memory warnings, increasing memory reported by the application itself (if it logs it), or the
SIGINT/SIGKILLsignals.Verify
max_memory_restart: Check your PM2 ecosystem file (ecosystem.config.jsor.json). A typical entry might look like this:// ecosystem.config.js module.exports = { apps : [{ name : "my-node-app", script : "./app.js", instances: "max", exec_mode: "cluster", // or "fork" max_memory_restart: "1G", // Example: restart if memory usage exceeds 1GB env: { NODE_ENV: "production" } }] };While
max_memory_restartcan prevent a full system crash, it does not fix the underlying memory leak. It merely provides a "band-aid" by restarting the process before it exhausts all available memory. Always aim to fix the leak in your code. For debugging purposes, you might temporarily increase it to buy more time for profiling, or even disable it if OOM killer is taking over too quickly, but revert this change after debugging.
2. Resource Monitoring & Baseline Establishment
Beyond PM2's built-in monitoring, get a clearer picture of your system's resource usage over time.
System-Level Monitoring: Use
htoportopto watch the Node.js process's real-time memory and CPU consumption. Observe if theRES(resident memory) value steadily increases over time without dropping.PM2
monit:pm2 monitThis provides a real-time terminal dashboard of your PM2 applications, showing CPU, memory, requests per minute, and event loop latency. It's excellent for quickly identifying which process instance (if running in cluster mode) is leaking.
Application-Level Memory Reporting: Add simple logging to your Node.js application to periodically report its memory usage.
// In your app.js or a health check endpoint setInterval(() => { const memoryUsage = process.memoryUsage(); console.log(`[${new Date().toISOString()}] WARN: Memory usage: RSS ${Math.round(memoryUsage.rss / 1024 / 1024)} MB, Heap Used ${Math.round(memoryUsage.heapUsed / 1024 / 1024)} MB`); }, 60 * 1000); // Log every minuteThis output in
pm2 logshelps confirm that the application itself is reporting growing memory usage. Focus onheapUsed.
3. Heap Dump Generation & Analysis
This is the most critical step for identifying the specific objects causing the leak.
Prepare your PM2 app for debugging: You need to start your application with V8 inspector enabled.
pm2 stop my-node-app # For a fork mode app: pm2 start my-node-app --node-args="--inspect=0.0.0.0:9229 --max-old-space-size=4096" # For a cluster mode app (you might want to debug one instance first): # Edit ecosystem.config.js: # module.exports = { # apps : [{ # name : "my-node-app", # script : "./app.js", # instances: 1, // Start only one instance for easier debugging # exec_mode: "fork", // Temporarily switch to fork for simpler debugging if needed # node_args: "--inspect=0.0.0.0:9229 --max-old-space-size=4096", # ... # }] # }; # pm2 reload ecosystem.config.js --env productionThe
--inspectflag enables the V8 inspector, which Chrome DevTools can connect to.--max-old-space-sizetemporarily increases the memory limit for the V8 heap to prevent premature OOM while you're collecting data. Ensure port9229(or your chosen port) is open in the firewall for your debugging machine if connecting remotely, or use an SSH tunnel.Reproduce the leak: With the app running in debug mode, interact with it in a way that triggers the memory leak. This might involve repeated requests to a specific endpoint or prolonged usage.
Connect Chrome DevTools:
- Open Chrome browser.
- Type
chrome://inspectin the address bar. - Click "Configure…" and add your server's IP and port (e.g.,
192.168.1.100:9229). - You should see your Node.js process listed under "Remote Target". Click "inspect".
Generate Heap Snapshots:
- In the DevTools window, go to the "Memory" tab.
- Select "Heap snapshot".
- Take an initial snapshot (Snapshot 1).
- Continue interacting with your application to trigger the leak, allowing memory usage to increase significantly (e.g., waiting 5-10 minutes, or performing 100-200 problematic actions).
- Take a second snapshot (Snapshot 2).
- Take a third snapshot (Snapshot 3) after more leak-inducing activity.
Analyze Heap Snapshots:
- Select Snapshot 3 and in the dropdown menu above the graph, choose "Comparison" mode, comparing it to Snapshot 2.
- Sort by "Diff" (total size delta) to see what objects have increased most in memory between the two snapshots.
- Look for objects with consistently increasing sizes or counts. Common culprits include:
- Arrays (e.g.,
Arrayobjects holding many references). - Objects (e.g.,
Objectinstances, often custom classes). - Strings (if large amounts of text are being stored).
- Event objects (e.g.,
EventEmitteror custom event handlers).
- Arrays (e.g.,
- Expand suspicious objects to see their "Retainers" (what's holding a reference to them) and "Dominators" (objects that prevent a set of objects from being garbage collected). This path usually leads back to your code.
- A good indicator is an increase in a specific constructor type that doesn't decrease after the operation that created them has completed.
Focus on the "Retainers" section in the heap snapshot. This tells you why an object is still in memory. The path from the "root" to your leaking object will pinpoint the exact reference preventing garbage collection.
4. CPU Profiling (if CPU is also high)
If your application also shows high CPU alongside memory growth, it might indicate inefficient code or excessive garbage collection cycles due to the leak.
- Start with Inspector: Use the same
--inspectsetup as for heap dumps. - Go to the "Performance" tab in Chrome DevTools.
- Click the record button (circle icon) and interact with your application.
- Stop recording and analyze the flame graph. Look for functions that consume a large percentage of CPU time, especially if they are related to data processing or object creation.
5. Code Review & Refactoring
Once the heap analysis points to specific areas or types of objects in your code, perform a targeted code review.
Unbounded Caches:
- Replace simple
MaporObjectcaches withlru-cacheor similar libraries that have eviction policies. - Example:
// Bad: unbounded cache const myCache = {}; function processData(id, data) { myCache[id] = data; // Data accumulates indefinitely // ... } // Good: LRU cache const LRUCache = require('lru-cache'); const myLRUCache = new LRUCache({ max: 500, ttl: 1000 * 60 * 5 }); // Max 500 items, expires after 5 mins function processData(id, data) { myLRUCache.set(id, data); // ... }
- Replace simple
Unreleased Event Listeners:
- Always use
emitter.removeListener(eventName, listener)when a listener is no longer needed. - Use
emitter.once()for listeners that should only fire once. - Ensure proper cleanup in class destructors or lifecycle hooks (e.g.,
componentWillUnmountin React, or a customdestroymethod for services). - Example:
// Bad: listener accumulates on 'req' for each HTTP request server.on('request', (req, res) => { const myProcessor = new DataProcessor(); // This object is short-lived req.on('data', myProcessor.handleData); // Listener added to 'req' // If DataProcessor instance is not garbage collected, it holds onto 'req' // and req holds onto it through the listener. }); // Good: ensure proper cleanup or avoid persistent listeners server.on('request', (req, res) => { const myProcessor = new DataProcessor(); function handleRequestData(chunk) { myProcessor.handleData(chunk); } req.on('data', handleRequestData); req.on('end', () => { req.removeListener('data', handleRequestData); // Crucial cleanup myProcessor.cleanup(); // Custom cleanup for processor res.end(); }); });
- Always use
Closures Retaining Large Scopes:
- Be mindful of variables captured by inner functions. If an inner function is returned and kept alive, it will also keep all variables from its parent scope alive.
- Refactor code to minimize the scope of captured variables, or explicitly nullify them when no longer needed.
Improper Stream Handling:
- Always ensure streams are piped correctly, drained, or explicitly
destroy()ed. - Use
pipelinefromstream/promisesorpumpfor robust stream error handling and automatic cleanup. - Example:
const { pipeline } = require('stream/promises'); const fs = require('fs'); async function processFile(filePath, outputStream) { const readStream = fs.createReadStream(filePath); try { await pipeline(readStream, outputStream); console.log('File processed successfully'); } catch (error) { console.error('Stream pipeline failed', error); // pipeline automatically destroys streams on error } }
- Always ensure streams are piped correctly, drained, or explicitly
Global Variable Management:
- Avoid using global variables for dynamic, potentially large data. If unavoidable, explicitly set them to
nullor an empty state when their data is no longer needed.
- Avoid using global variables for dynamic, potentially large data. If unavoidable, explicitly set them to
6. PM2 max_memory_restart & kill_timeout Tuning
After you've identified and fixed the memory leak in your code, you can fine-tune PM2's resilience.
Adjust
max_memory_restart: Set this value based on the application's expected stable memory footprint plus a reasonable buffer. For example, if your app typically uses 250MB, setting it to512Mor768Mmight be appropriate.// ecosystem.config.js module.exports = { apps : [{ name : "my-node-app", script : "./app.js", max_memory_restart: "768M", // Adjusted after fixing leak kill_timeout: 30000, // Give app 30 seconds to gracefully shut down ... }] };Set
kill_timeout: This parameter (in milliseconds) specifies how long PM2 waits for your application to gracefully exit after sending aSIGINT(orSIGTERMif specified). If the app doesn't exit within this time, PM2 sends aSIGKILL. A longer timeout (e.g., 10-30 seconds) can help prevent data corruption during restarts, allowing pending requests to complete or resources to be released.Apply changes:
pm2 reload ecosystem.config.js --env production
7. Garbage Collection Tuning (Advanced)
V8's garbage collector is highly optimized, and rarely requires manual tuning for basic memory leaks. However, in very specific scenarios with unique memory access patterns, adjusting V8 flags might offer marginal improvements.
Modifying V8 garbage collection flags should be considered a last resort and is generally not recommended unless you have a deep understanding of V8 internals. Incorrect settings can lead to worse performance, increased CPU usage, or even more frequent OOM errors.
Common V8 flags for memory management (add to node_args in your PM2 config):
--max-old-space-size=N: Set the maximum size of the old object heap in MB. (e.g.,4096for 4GB). This is often used to delay OOM, but doesn't fix a leak.--initial-old-space-size=N: Set the initial size of the old object heap in MB.--optimize_for_size: Prioritize memory usage over execution speed.
Example (use with caution):
// ecosystem.config.js
module.exports = {
apps : [{
name : "my-node-app",
script : "./app.js",
node_args: "--max-old-space-size=3072 --optimize_for_size",
...
}]
};
8. Implement Health Checks & Alerting
Proactive monitoring can help detect memory issues before they lead to infinite restart loops.
Application Health Check Endpoint: Expose an HTTP endpoint (e.g.,
/health) that reports application status, includingprocess.memoryUsage().Monitoring System Integration:
- Prometheus/Grafana: Instrument your Node.js application with a
prom-clientto expose V8 memory metrics, HTTP request metrics, etc. Set up Grafana dashboards and Prometheus alerts to notify you when heap usage or RSS consistently exceeds thresholds. - Systemd: Configure Systemd to restart the PM2 service itself if it fails repeatedly, or if the system hits memory pressure.
- Cloud Monitoring: Leverage AWS CloudWatch, Azure Monitor, Google Cloud Monitoring to track host memory usage and alert on anomalies.
- Prometheus/Grafana: Instrument your Node.js application with a
By following these detailed steps, you can effectively diagnose and resolve Node.js memory leaks managed by PM2, leading to more robust and reliable production applications.