Memory Management in Node.js Applications

Bishnu AdhikariBishnu Adhikari
7 min read

Understanding memory management is essential for building reliable, scalable systems. Let me share what I've learned about how Node.js handles memory and how to avoid the pitfalls that can bring down your application.

Understanding the V8 Memory Architecture

Node.js runs on Google's V8 JavaScript engine, which manages memory through a sophisticated garbage collection system. V8 divides memory into several key areas:

Heap Memory is where your JavaScript objects live. It's split into two main generations:

  • Young Generation (New Space): Short-lived objects start here. Most objects die young, so V8 can quickly clean up this space.

  • Old Generation (Old Space): Objects that survive multiple garbage collection cycles get promoted here. This space is larger but cleaned less frequently.

Stack Memory stores function calls, local variables, and execution context. It's automatically managed and typically not where your memory problems lie.

The default heap size limits are often insufficient for production applications. On 64-bit systems, V8 defaults to around 1.4GB for the old generation, but you can increase this with the --max-old-space-size flag:

node --max-old-space-size=4096 app.js  # Sets limit to 4GB

The Garbage Collection Process

V8 uses a generational garbage collector with two primary algorithms:

Scavenge operates on the young generation using a copying collector. It's fast but can only reclaim objects that are completely unreachable. This runs frequently and usually completes in under 10ms.

Mark-Sweep-Compact handles the old generation. It marks reachable objects, sweeps away unreachable ones, and compacts the remaining objects to reduce fragmentation. This is more expensive and can cause noticeable pauses in your application.

Understanding these processes helps explain why certain coding patterns can cause performance issues. For example, creating many short-lived objects in a tight loop can overwhelm the scavenge process, while circular references can prevent objects from being collected entirely.

Common Memory Leak Patterns

From my experience debugging production systems, here are the most common memory leak patterns I encounter:

  1. Closures Holding References are probably the most subtle leak source. When a closure captures variables from its outer scope, those variables remain in memory as long as the closure exists:
function createHandler() {
    const largeData = new Array(1000000).fill('data');

    return function(req, res) {
        // This closure keeps largeData alive even if it's never used
        res.json({ status: 'ok' });
    };
}
  1. Global Variables accumulate over time, especially when you're not careful about cleanup:
// This grows indefinitely
global.cache = global.cache || {};
global.cache[userId] = userData;  // Never cleaned up
  1. Event Listener Leaks happen when you add listeners but forget to remove them:
function setupUserSocket(socket) {
    const handler = (data) => processUserData(data);

    socket.on('data', handler);
    // Forgot to socket.removeListener('data', handler) on disconnect
}
  1. Timer and Interval Leaks keep objects alive through their callback references:
const timer = setInterval(() => {
    // If this callback references objects that should be GC'd,
    // they'll stay in memory until the timer is cleared
    processExpensiveOperation();
}, 1000);
// Forgot to clearInterval(timer)

Memory Monitoring and Profiling

You cannot manage what you don't measure. Always implement memory monitoring in production applications:

// Basic memory monitoring
setInterval(() => {
    const memUsage = process.memoryUsage();
    console.log({
        rss: Math.round(memUsage.rss / 1024 / 1024) + ' MB',
        heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024) + ' MB',
        heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024) + ' MB',
        external: Math.round(memUsage.external / 1024 / 1024) + ' MB'
    });
}, 30000);

For deeper analysis, V8's built-in profiling tools are invaluable:

import v8 from 'node:v8';
import fs from 'node:fs';

export function takeHeapSnapshot() {
  const snapshotStream = v8.getHeapSnapshot();
  const fileName = `heap-${Date.now()}.heapsnapshot`;
  const fileStream = fs.createWriteStream(fileName);
  snapshotStream.pipe(fileStream);
  return fileName;
}

We can set up automated heap snapshot generation triggered by memory thresholds or admin-only HTTP endpoints, which allows us to capture the application state when memory usage spikes.

Real-World Memory Monitoring

Don't wait for problems to happen. Set up monitoring that actually helps you catch issues early.

class ProductionMemoryMonitor {
    constructor() {
        this.alerts = [];
        this.baselineMemory = process.memoryUsage().heapUsed;
        this.maxGrowth = this.baselineMemory * 2; // Alert if memory doubles

        this.startMonitoring();
    }

    startMonitoring() {
        setInterval(() => {
            this.checkMemoryHealth();
        }, 10 * 60 * 1000); // Check every 10 minutes
    }

    checkMemoryHealth() {
        const mem = process.memoryUsage();
        const currentHeap = mem.heapUsed;
        const growthRatio = currentHeap / this.baselineMemory;

        // Log memory stats
        console.log(`Memory Health Check: ${Math.round(currentHeap / 1024 / 1024)}MB used (${growthRatio.toFixed(2)}x baseline)`);

        // Alert if memory has grown significantly
        if (currentHeap > this.maxGrowth && this.alerts.length === 0) {
            this.sendAlert('Memory usage has doubled from baseline', {
                current: currentHeap,
                baseline: this.baselineMemory,
                ratio: growthRatio
            });
            // Also take a heap snapshot here for profiling
        }
    }

    sendAlert(message, data) {
        const alert = { message, data, timestamp: new Date() };
        this.alerts.push(alert);

        // Send to your monitoring system (Slack, email, etc.)
        console.error('MEMORY ALERT:', alert);
    }
}

// Start monitoring when your app starts
const memoryMonitor = new ProductionMemoryMonitor();

Practical Memory Optimization Strategies

After dealing with memory issues for years, I've learned that the best fixes are usually the simplest ones. Here are the strategies that have saved me the most headaches in production.

  1. Confirm You Actually Have a Memory Problem

Don't assume. Measure it. I've seen teams panic over "memory leaks" that were just normal application growth.

let memoryLog = [];

setInterval(() => {
    const mem = process.memoryUsage();
    const memInfo = {
        timestamp: new Date().toISOString(),
        heapUsed: Math.round(mem.heapUsed / 1024 / 1024), // MB
        heapTotal: Math.round(mem.heapTotal / 1024 / 1024), // MB
        rss: Math.round(mem.rss / 1024 / 1024) // MB
    };

    memoryLog.push(memInfo);

    // Keep only last hour of data
    if (memoryLog.length > 120) { // 120 * 30sec = 1 hour
        memoryLog.shift();
    }

    console.log(`Memory: ${memInfo.heapUsed}MB used, ${memInfo.heapTotal}MB total`);
}, 30000); // Every 30 seconds
  1. Stream Large Data Instead of Buffering It

Loading large files or responses entirely into memory is a guaranteed way to blow through heap space. Node’s streams let you process data in constant memory, while backpressure prevents consumers from being overwhelmed.

import { createReadStream } from 'node:fs';
import readline from 'node:readline';

async function processBigFile(filepath) {
  const stream = createReadStream(filepath, { highWaterMark: 64 * 1024 });
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });

  for await (const line of rl) {
    await processLine(line); // stays O(1) in memory
  }
}

Takeaway: Whether it’s a 100MB log file or a multi-gigabyte CSV, stream it, don’t buffer it.

  1. Use WeakMap for Ephemeral Metadata

Attaching metadata to objects with a Map can keep them alive forever, even if you don’t need them anymore. WeakMap solves this by allowing the garbage collector to clean up keys when the objects go out of scope.

const metadata = new WeakMap();

function attachInfo(object, info) {
  metadata.set(object, info);
}

function getInfo(object) {
  return metadata.get(object); // disappears once object is GC’d
}

Takeaway: When metadata should live only as long as the object does, reach for WeakMap.

  1. Bound Your Caches with LRU Eviction

An unbounded cache is just a slow-burning memory leak. Always cap cache size and use a strategy like Least Recently Used (LRU) to keep hot items while discarding cold ones.

// I've kept it minimal for the example
class LRUCache {
  constructor(limit = 1000) {
    this.limit = limit;
    this.map = new Map();
  }

  get(key) {
    if (!this.map.has(key)) return;
    const value = this.map.get(key);
    this.map.delete(key);
    this.map.set(key, value); // promote
    return value;
  }

  set(key, value) {
    if (this.map.has(key)) this.map.delete(key);
    this.map.set(key, value);
    if (this.map.size > this.limit) {
      const oldest = this.map.keys().next().value;
      this.map.delete(oldest);
    }
  }
}

Takeaway: If your cache has no eviction policy, it’s not a cache - it’s a memory leak.

  1. Pool Only Expensive Resources (Not Plain JS Objects)

V8 can allocate plain objects faster than any pool you build. What’s worth pooling are external or expensive resources: DB connections, buffers, HTTP agents.

// Example: HTTP agent with keep-alive pooling
import http from 'node:http';

const keepAliveAgent = new http.Agent({ keepAlive: true, maxSockets: 50 });

async function fetchJson(url) {
  return new Promise((resolve, reject) => {
    http.get(url, { agent: keepAliveAgent }, (res) => {
      const chunks = [];
      res.on('data', (c) => chunks.push(c));
      res.on('end', () => resolve(JSON.parse(Buffer.concat(chunks))));
    }).on('error', reject);
  });
}

Takeaway: Don’t pool plain objects. Pool what’s actually expensive to create.

  1. Measure and Profile Memory Regularly

The golden rule: you can’t optimize what you don’t measure. Node provides process.memoryUsage() and V8 exposes heap statistics you can log and alert on.

import { getHeapStatistics } from 'node:v8';

setInterval(() => {
  const mem = process.memoryUsage();
  const stats = getHeapStatistics();
  console.log({
    rss: mem.rss,
    heapUsed: mem.heapUsed,
    heapTotal: mem.heapTotal,
    heapLimit: stats.heap_size_limit
  });
}, 15000);

In staging or production, pair this with heap snapshots (--inspect, Chrome DevTools, or Clinic.js) to identify leaks early.

Takeaway: Treat memory like any other critical metric. Monitor it continuously and act on growth patterns.

Conclusion

The V8 garbage collector will do its job, but it’s on us as engineers to design software that cooperates with it. That means respecting the heap, avoiding unbounded growth, streaming data instead of hoarding it, and building observability from day one.

0
Subscribe to my newsletter

Read articles from Bishnu Adhikari directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Bishnu Adhikari
Bishnu Adhikari