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:

  1. PM2 status or list showing high restarts count:

    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  │
    
  2. PM2 logs indicating frequent restarts, potentially due to SIGINT or SIGKILL:

    pm2 logs my-node-app --lines 50
    
    0|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 SIGINT often implies PM2's max_memory_restart limit was hit, triggering a graceful restart. If it's SIGKILL, the process might have been forcefully terminated by the kernel's Out-Of-Memory (OOM) killer.

  3. High system memory utilization via top/htop:

    htop
    

    You'll see one or more node processes consuming a disproportionately large and often continuously increasing amount of RAM.

  4. dmesg output 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:0kB
    

    This 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:

  1. 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.
  2. Unreleased Event Listeners: Event emitters (e.g., EventEmitter, streams, HTTP servers) can retain references to listeners. If listeners are added repeatedly without removeListener() being called, especially on short-lived objects, they can accumulate and prevent garbage collection of the entire scope they enclose.
  3. 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.
  4. Improper Stream Handling: Forgetting to drain(), destroy(), or properly close streams (e.g., file streams, network streams) can lead to buffer accumulation.
  5. Global Variables: Assigning large objects or data to global variables (global or module-scoped variables) and never nullifying them.
  6. Third-Party Library Issues: Sometimes, the leak might originate from an external dependency that itself has a memory management bug.
  7. PM2 max_memory_restart Trigger: While not a root cause of the leak, a poorly configured max_memory_restart value 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.

  1. Check PM2 Application Status:

    pm2 list
    pm2 describe my-node-app
    

    Note the restarts count, memory usage, and the configured max_memory_restart value from pm2 describe.

  2. Review PM2 Logs:

    pm2 logs my-node-app --lines 100 --timestamp
    

    Look for patterns indicating memory warnings, increasing memory reported by the application itself (if it logs it), or the SIGINT/SIGKILL signals.

  3. Verify max_memory_restart: Check your PM2 ecosystem file (ecosystem.config.js or .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_restart can 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.

  1. System-Level Monitoring: Use htop or top to watch the Node.js process's real-time memory and CPU consumption. Observe if the RES (resident memory) value steadily increases over time without dropping.

  2. PM2 monit:

    pm2 monit
    

    This 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.

  3. 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 minute
    

    This output in pm2 logs helps confirm that the application itself is reporting growing memory usage. Focus on heapUsed.

3. Heap Dump Generation & Analysis

This is the most critical step for identifying the specific objects causing the leak.

  1. 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 production
    

    The --inspect flag enables the V8 inspector, which Chrome DevTools can connect to. --max-old-space-size temporarily increases the memory limit for the V8 heap to prevent premature OOM while you're collecting data. Ensure port 9229 (or your chosen port) is open in the firewall for your debugging machine if connecting remotely, or use an SSH tunnel.

  2. 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.

  3. Connect Chrome DevTools:

    • Open Chrome browser.
    • Type chrome://inspect in 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".
  4. 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.
  5. 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., Array objects holding many references).
      • Objects (e.g., Object instances, often custom classes).
      • Strings (if large amounts of text are being stored).
      • Event objects (e.g., EventEmitter or custom event handlers).
    • 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.

  1. Start with Inspector: Use the same --inspect setup as for heap dumps.
  2. Go to the "Performance" tab in Chrome DevTools.
  3. Click the record button (circle icon) and interact with your application.
  4. 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.

  1. Unbounded Caches:

    • Replace simple Map or Object caches with lru-cache or 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);
          // ...
      }
      
  2. 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., componentWillUnmount in React, or a custom destroy method 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();
          });
      });
      
  3. 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.
  4. Improper Stream Handling:

    • Always ensure streams are piped correctly, drained, or explicitly destroy()ed.
    • Use pipeline from stream/promises or pump for 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
          }
      }
      
  5. Global Variable Management:

    • Avoid using global variables for dynamic, potentially large data. If unavoidable, explicitly set them to null or an empty state when their data is no longer needed.

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.

  1. 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 to 512M or 768M might 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
        ...
      }]
    };
    
  2. Set kill_timeout: This parameter (in milliseconds) specifies how long PM2 waits for your application to gracefully exit after sending a SIGINT (or SIGTERM if specified). If the app doesn't exit within this time, PM2 sends a SIGKILL. 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.

  3. 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., 4096 for 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.

  1. Application Health Check Endpoint: Expose an HTTP endpoint (e.g., /health) that reports application status, including process.memoryUsage().

  2. Monitoring System Integration:

    • Prometheus/Grafana: Instrument your Node.js application with a prom-client to 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.

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.