Runtimes Advanced

Troubleshooting & Resolving NodeJS PM2 Infinite Restart Loop from Memory Leaks on Ubuntu 20.04 LTS

Diagnose and fix Node.js PM2 applications endlessly restarting due to memory leaks on Ubuntu 20.04. Dive into root causes, heap analysis, and robust resolutions.

👨‍💻
Senior Systems Architect • Verified in Staging Labs

Diagnose and fix Node.js PM2 applications endlessly restarting due to memory leaks on Ubuntu 20.04. Dive into root causes, heap analysis, and robust resolutions.

Introduction

As a seasoned Systems Administrator, encountering a critical application service trapped in an infinite restart loop is a familiar, high-stress scenario. When your Node.js application, managed by PM2 on an Ubuntu 20.04 LTS server, begins exhibiting this behavior, it often points to a severe memory leak. The service starts, consumes an increasing amount of RAM until it hits a system or PM2-defined limit, crashes with an "out of memory" error, and is then immediately restarted by PM2, only to repeat the cycle. This not only renders your application unusable but also indicates a fundamental resource management issue within your codebase.

This guide provides an expert-level, systematic approach to diagnose, debug, and ultimately resolve these pernicious memory leaks, ensuring your Node.js services run stably and efficiently.

Symptom & Error Signature

Users will experience intermittent service availability, slow response times, or complete application unresponsiveness. From a system perspective, you'll observe the PM2 process status cycling rapidly between online, errored, or restarting. High CPU and memory utilization will precede each crash.

Typical observations from PM2 and system logs:

PM2 Status Output:

$ pm2 status

┌─────┬────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┐
│ id  │ name       │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │
├─────┼────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┤
│ 0   │ my-nodejs-app │ default     │ 1.0.0   │ fork    │ 24567    │ 0s     │ 256  │ errored   │ 0%       │ 0B       │ www-data │
└─────┴────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┘

Notice the (restart count) rapidly increasing and status frequently changing. The mem column might show rapid growth before crashing.

PM2 Application Logs (pm2 logs <app-name>):

0|my-nodejs-app  | <--- Last few GCs --->
0|my-nodejs-app  |
0|my-nodejs-app  | [24567:0x51c70e0]   213382 ms: Scavenge 2029.2 (2055.6) -> 2028.6 (2056.1) MB, 0.6 / 0.0 ms  (average mu = 0.992, average rss = 1918.57) allocation failure
0|my-nodejs-app  | [24567:0x51c70e0]   213383 ms: Scavenge 2029.2 (2055.6) -> 2028.6 (2056.1) MB, 0.7 / 0.0 ms  (average mu = 0.992, average rss = 1918.57) allocation failure
0|my-nodejs-app  | [24567:0x51c70e0]   213384 ms: Scavenge 2029.2 (2055.6) -> 2028.6 (2056.1) MB, 0.7 / 0.0 ms  (average mu = 0.992, average rss = 1918.57) allocation failure
0|my-nodejs-app  | [24567:0x51c70e0]   213385 ms: Scavenge 2029.2 (2055.6) -> 2028.6 (2056.1) MB, 0.7 / 0.0 ms  (average mu = 0.992, average rss = 1918.57) allocation failure
0|my-nodejs-app  | [24567:0x51c70e0]   213386 ms: Scavenge 2029.2 (2055.6) -> 2028.6 (2056.1) MB, 0.7 / 0.0 ms  (average mu = 0.992, average rss = 1918.57) allocation failure
0|my-nodejs-app  |
0|my-nodejs-app  | <--- JS stacktrace --->
0|my-nodejs-app  |
0|my-nodejs-app  | FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
0|my-nodejs-app  |  1: 0xb007c0 node::OnFatalError(char const*, char const*) [node]
0|my-nodejs-app  |  2: 0xb4e61e v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, bool) [node]
0|my-nodejs-app  |  3: 0xb4e997 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, bool) [node]
0|my-nodejs-app  |  4: 0xd0a315 v8::internal::Heap::CheckIneffectiveMarkCompact(v8::internal::GarbageCollector) [node]
0|my-nodejs-app  |  5: 0xd0b672 v8::internal::Heap::PerformGarbageCollection(v8::internal::GarbageCollector, v8::internal::GarbageCollectionReason) [node]
0|my-nodejs-app  |  6: 0xd0c2a2 v8::internal::Heap::CollectGarbage(v8::internal::GarbageCollector, v8::internal::GarbageCollectionReason) [node]
0|my-nodejs-app  |  7: 0xd0e83b v8::internal::Heap::AllocateRawWithRetryOrFail(int, v8::internal::AllocationType) [node]
0|my-nodejs-app  |  8: 0xcd694d v8::internal::Factory::NewFillerObject(int, v8::internal::AllocationType) [node]
0|my-nodejs-app  |  9: 0x10080ae v8::internal::Runtime_AllocateInYoungGeneration(int, unsigned long*, v8::internal::Isolate*) [node]
0|my-nodejs-app  | 10: 0x146be1d Builtins_CEntry_Return1_DontSaveFPRegs_ArgvOnStack_NoBuiltinExit [node]

This FATAL ERROR: ... JavaScript heap out of memory is the definitive signature of a memory leak causing a crash.

Root Cause Analysis

A Node.js application memory leak on Ubuntu 20.04 LTS managed by PM2 typically stems from one of the following underlying issues:

  1. Application Code Defects (Most Common):

    • Unreleased References: Objects or closures are kept alive in memory unintentionally, preventing them from being garbage collected. This often occurs with event listeners, timers (setInterval), or cached data that is never cleared.
    • Global Variables/Caches: Large data structures stored in global variables or poorly managed in-memory caches that grow indefinitely without bounds or eviction policies.
    • Improper Stream Handling: Failure to properly close or pipe Node.js streams, leading to buffered data accumulating in memory.
    • Recursive Functions: Deep or infinite recursion without proper base cases, exhausting the call stack (though this typically results in a stack overflow, not a heap OOM).
    • Fast Growing Data Structures: Arrays or objects that append data in a loop or high-frequency operation without bounds.
    • Asynchronous Operations: Accumulating results from many concurrent async operations that are not properly processed or released.
  2. Third-Party Library Bugs:

    • A dependency you're using might have its own memory leak issues. This is especially true for libraries dealing with complex data processing, native bindings, or persistent connections.
  3. Node.js Runtime or V8 Engine Quirks:

    • While less common in stable Node.js versions, certain interaction patterns or edge cases might expose V8 garbage collection inefficiencies, although usually these are optimized away.
    • The default V8 heap size limit might be too restrictive for specific workloads, but a persistent leak will eventually exceed any limit.
  4. PM2 Configuration (Exacerbating Factor, not Root Cause):

    • A missing max_memory_restart setting in PM2's configuration will allow the process to consume all available system RAM before crashing, making diagnosis harder. While not the cause of the leak, it prevents PM2 from attempting to mitigate it proactively.
  5. External Resource Management:

    • Databases connections, file handles, or network sockets that are opened but never closed, consuming system resources, eventually leading to process instability.

Step-by-Step Resolution

Resolving a Node.js memory leak requires a methodical approach, often involving profiling and code inspection.

1. Initial Diagnosis with PM2 & System Monitoring

Before diving into code, confirm the issue and gather basic data.

  • Check PM2 Status and Logs:

    pm2 status
    pm2 logs <app-name> --lines 200 --err # Focus on error logs
    

    Look for the FATAL ERROR: JavaScript heap out of memory message.

  • Monitor System Resources: Use htop or top to observe overall system memory usage. Pay attention to the specific Node.js process's RES (Resident Set Size) and VIRT (Virtual Memory Size) metrics.

    htop
    

    Alternatively, use free -h to see overall memory.

    watch -n 1 'free -h'
    
  • Use PM2 Monitoring Tools: PM2 provides a basic interactive monitoring dashboard.

    pm2 monit
    

    Observe memory and CPU usage trends. A steady, uninterrupted upward trend in memory for your app is a strong indicator of a leak.

2. Configure PM2 for Stability & Diagnostics

Modify your PM2 ecosystem file (ecosystem.config.js or .json) to include max_memory_restart. This tells PM2 to restart the process before it crashes the entire system, allowing for some graceful degradation and providing a clear signal that a leak is present.

// ecosystem.config.js
module.exports = {
  apps : [{
    name      : 'my-nodejs-app',
    script    : 'index.js',
    instances : 1,
    exec_mode : 'fork', // or 'cluster'
    env: {
      NODE_ENV: 'production',
      # NODE_OPTIONS: '--max-old-space-size=2048' // Optional: Increase V8 heap size if needed, but only as a temporary measure/band-aid, not a fix for leaks.
    },
    autorestart: true,
    watch: false,
    max_memory_restart: '1024M', // Restart if memory exceeds 1GB
    log_file: './logs/combined.log',
    error_file: './logs/error.log',
    out_file: './logs/out.log',
    merge_logs: true,
    log_date_format: 'YYYY-MM-DD HH:mm:ss'
  }]
};

Set max_memory_restart to a value comfortably below your server's total RAM, or a value that represents a reasonable upper limit for your application's expected memory footprint. This helps prevent the application from consuming all system resources and bringing down other services. After modification, deploy and restart PM2:

pm2 reload ecosystem.config.js --env production

3. Enable Node.js Inspector for Heap Profiling

Node.js has a powerful built-in inspector that allows you to profile memory using Chrome DevTools.

  • Modify PM2 Configuration to Expose Inspector: Add NODE_OPTIONS to your env or env_production section in ecosystem.config.js. You'll need to choose an available port.

    // ecosystem.config.js
    module.exports = {
      apps : [{
        // ... other settings
        env_production : {
          NODE_ENV: 'production',
          NODE_OPTIONS: '--inspect=0.0.0.0:9229', // Expose inspector on all interfaces, port 9229
        }
      }]
    };
    

    Exposing the Node.js inspector on 0.0.0.0 (all interfaces) in a production environment is a major security risk if not properly secured. You must restrict access to port 9229 (or your chosen port) via a firewall (e.g., UFW, iptables) to only your local machine or trusted IP addresses.

    Example UFW rule (replace YOUR_TRUSTED_IP):

    sudo ufw allow from YOUR_TRUSTED_IP to any port 9229
    sudo ufw reload
    

    For internal use only, consider 127.0.0.1:9229 and using SSH tunneling.

  • Restart PM2 with Inspector Enabled:

    pm2 reload ecosystem.config.js --env production
    

    Verify it's running:

    pm2 logs my-nodejs-app --lines 10 # Look for "Debugger listening on ws://..."
    
  • Connect Chrome DevTools:

    1. Open Chrome browser.
    2. Go to chrome://inspect.
    3. Click "Configure…" and add your server's IP address and the inspector port (e.g., YOUR_SERVER_IP:9229).
    4. You should see your Node.js target listed. Click "inspect".
    5. In the DevTools window, navigate to the "Memory" tab.
  • Perform Heap Snapshots:

    1. Start recording (click the circle icon or "Record heap snapshot").
    2. Let your application run for a period (e.g., 5-10 minutes, or until memory noticeably grows in pm2 monit).
    3. Take a second heap snapshot.
    4. Repeat taking snapshots as the application runs, especially when you observe memory increasing.
    5. Compare the snapshots. In the "Summary" view of the second snapshot, choose the first snapshot from the dropdown menu (e.g., "Snapshot 2 vs Snapshot 1").
    6. Sort by "Delta" to see objects that were newly allocated and not garbage collected.
    7. Look for large numbers of specific object types, or objects that increase steadily in count and size. This will point you to the functions or modules responsible.

    Focus on the "Retainers" section for leaked objects. It shows the chain of references keeping the object alive. This is crucial for identifying the root of the leak in your code.

4. Advanced Debugging with clinic.js (Optional but Powerful)

clinic.js is an excellent suite of Node.js performance tools. clinic doctor can help diagnose various performance issues, including memory leaks.

  • Install Clinic.js:

    npm install -g clinic
    
  • Run Clinic Doctor (temporarily disable PM2 for this): First, stop your app with PM2:

    pm2 stop my-nodejs-app
    

    Then, run your application with clinic doctor:

    clinic doctor -- node index.js
    

    Let it run for a period, ideally under load. After stopping (Ctrl+C), clinic doctor will generate an HTML report. This report often provides clear recommendations and visualizations of CPU, memory, and event loop usage, helping pinpoint bottlenecks and leaks.

    Running clinic doctor directly on a production server might impact performance due to instrumentation overhead. It's often better to reproduce the issue in a staging environment. If you must run on production, do so during low traffic periods.

5. Code Review and Optimization

Once you've identified potential areas from heap snapshots or clinic doctor reports, perform a targeted code review.

  • Look for Common Leak Patterns:

    • Event Emitters: Are you adding event listeners without removing them? (e.g., emitter.on('data', handler) without emitter.off('data', handler) later).
    • setInterval/setTimeout: Are timers cleared with clearInterval/clearTimeout when no longer needed?
    • Caches: Are you using a cache that grows indefinitely? Implement LRU (Least Recently Used) or time-based eviction policies (e.g., lru-cache npm package).
    • Closures: Are closures holding references to larger scopes that prevent garbage collection?
    • Global Variables: Any global arrays or objects where data accumulates?
    • Streams: Ensure all streams are properly ended or destroyed. stream.pipe() handles this automatically, but manual stream manipulation requires careful stream.on('end', ...) or stream.destroy() calls.
    • Database Connections/Resource Pools: Ensure connections are released back to the pool after use.
  • Example: Unreleased Event Listener Leak:

    // BAD EXAMPLE: Potential memory leak
    class LeakyComponent extends EventEmitter {
      constructor() {
        super();
        this.data = [];
        // Each time method is called, a new listener is added without removal
        setInterval(() => this.emit('tick'), 1000); // This part is fine
      }
    
      processRequest(req, res) {
        req.on('data', (chunk) => {
          this.data.push(chunk); // 'data' listener on request object
        });
        // If 'data' is slow or request aborted, listener might persist on old request object
        // And 'this.data' keeps growing
        req.on('end', () => {
          res.send('Processed');
          // 'this.data' should ideally be cleared or handled properly per request
        });
      }
    }
    
    // GOOD EXAMPLE: Better handling
    class StableComponent extends EventEmitter {
      constructor() {
        super();
        setInterval(() => this.emit('tick'), 1000);
      }
    
      processRequest(req, res) {
        const requestData = [];
        const onData = (chunk) => {
          requestData.push(chunk);
        };
        const onEnd = () => {
          req.off('data', onData); // Crucial: remove listener
          req.off('end', onEnd);   // Crucial: remove listener
          // Process requestData and send response
          res.send('Processed');
        };
        const onClose = () => { // Handle aborted connections
          req.off('data', onData);
          req.off('end', onEnd);
          req.off('close', onClose);
          console.warn('Request closed prematurely');
        };
    
        req.on('data', onData);
        req.on('end', onEnd);
        req.on('close', onClose); // Important for robustness
      }
    }
    

6. Update Node.js and Dependencies

Sometimes, the leak might be in an older version of Node.js or a specific third-party package.

  • Update Node.js: Ensure you are running a currently supported LTS version of Node.js (e.g., if you're on Node.js 14, consider upgrading to 16 or 18 if compatible). Use n or nvm for easy management.

    # Example using nvm
    nvm install --lts
    nvm use --lts
    # Or to specify a version
    nvm install 18
    nvm use 18
    # Update PM2's environment to use the new Node.js version if not global
    pm2 reload ecosystem.config.js --update-env
    
  • Update NPM Packages: Update your package.json dependencies to their latest compatible versions using npm outdated and npm update. Pay special attention to libraries identified during profiling.

    cd /path/to/your/app
    npm outdated
    npm update --save # or manually update package.json then npm install
    

7. Temporary Mitigation: Increase max-old-space-size (Use with Caution)

While not a fix for a memory leak, increasing the V8 heap size can buy you time to implement a proper solution by allowing your application to consume more memory before crashing. This should only be a temporary measure.

  • Modify PM2 Configuration:

    // ecosystem.config.js
    module.exports = {
      apps : [{
        // ... other settings
        env_production : {
          NODE_ENV: 'production',
          NODE_OPTIONS: '--max-old-space-size=4096', // Increase heap to 4GB
          // ... other options like --inspect if needed
        }
      }]
    };
    

    Increasing max-old-space-size can lead to longer, more disruptive garbage collection pauses, which can negatively impact application responsiveness. It merely delays the inevitable crash if a true memory leak exists. Use this only when actively working on a code fix.

8. Final Deployment and Monitoring

After implementing fixes and testing in a staging environment:

  1. Deploy your updated code to production.
  2. Restart your PM2 application: pm2 reload my-nodejs-app.
  3. Continuously monitor PM2 status (pm2 monit) and system resources (htop, free -h) to ensure the memory usage stabilizes at an expected level and the restart count remains low or static.
  4. Keep an eye on application logs for any new or recurring FATAL ERROR messages.

By following these rigorous steps, you can effectively pinpoint and resolve even the most elusive Node.js memory leaks, restoring stability and performance to your applications on Ubuntu 20.04 LTS.

👨‍💻

Johnathon Wheeler

Senior Systems Architect & DevOps Engineer • Austin, TX

Connect on LinkedIn

Johnathon has over 16 years of hands-on experience designing, debugging, and scaling Linux web hosting stacks, container clusters, and high-availability database architectures. Every guide on ButItWorkedLocal is independently tested against Debian 12, Ubuntu 24.04/22.04 LTS, Rocky Linux, and Docker environments to guarantee reproducibility in production.

🛡️

Our Production Verification Guarantee

Encountering a bug not covered here or running a non-standard kernel configuration? Our solutions are continually refined against real production incidents. Submit an environment trace for our editorial team to replicate.