ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

Ā© 2026 ZyVOP. Crafted with care for the developer community.

Made with ā¤ļø by the ZyVOP team
All systems operational
HomeNode.js Memory Leaks: How to Find Them, Fix Them, and Stop Writing Them

Node.js Memory Leaks: How to Find Them, Fix Them, and Stop Writing Them

The six patterns, a production-safe profiling workflow, GC pressure vs true leaks, and a diagnostic decision tree that tells you exactly where to look

#Node.js memory leak#Node.js heap snapshot#Node.js performance 2026#event listener memory leak#lru-cache Node.js#Node.js profiling#process.memoryUsage#Node.js stream memory leak#WeakMap Node.js#Node.js GC pressure
Z
ZyVOP

Senior Developer

May 26, 2026
16 min read
5 views
Node.js Memory Leaks: How to Find Them, Fix Them, and Stop Writing Them

Memory leaks in Node.js have a reputation for being mysterious. They are not. There is a small, well-defined set of causes responsible for nearly every production memory leak, and once you know what they look like, you start seeing them before they become incidents.

This guide covers how to detect a leak in production, the six patterns that cause almost all Node.js memory leaks, how to fix each one, and a profiling workflow you can run in under 20 minutes.


How to Know You Have a Leak

The first signal is almost always a memory graph that slopes up and never comes down. CPU stays normal, response times are fine, but RSS (Resident Set Size) climbs steadily over hours or days until the process runs out of memory and crashes — or the OOM killer terminates it.

A healthy process has memory that grows under load and shrinks when traffic drops. A leaking process has memory that only goes up.

Check current memory usage:

# For a running container
docker stats your-container-name

# In the application
process.memoryUsage()
// Returns: { rss, heapTotal, heapUsed, external, arrayBuffers }

Add this to your metrics endpoint and track it over time:

import client from 'prom-client';

const heapUsed = new client.Gauge({
  name: 'nodejs_heap_used_bytes',
  help: 'Node.js heap used in bytes',
});

const rss = new client.Gauge({
  name: 'nodejs_rss_bytes',
  help: 'Node.js RSS in bytes',
});

setInterval(() => {
  const mem = process.memoryUsage();
  heapUsed.set(mem.heapUsed);
  rss.set(mem.rss);
}, 10_000);

If heapUsed trends upward over hours with no corresponding increase in traffic, you have a leak. If RSS grows but heapUsed is stable, suspect native addons or buffers.


The Six Causes of Almost Every Node.js Memory Leak

1. Unbounded In-Process Caches

The most common leak. A developer adds a Map or plain object as a cache, things get stored in it, and nothing ever removes them.

// LEAKS — grows forever, nothing evicted
const cache = new Map<string, UserProfile>();

async function getUserProfile(userId: string) {
  if (cache.has(userId)) return cache.get(userId);
  const profile = await db.getUser(userId);
  cache.set(userId, profile);    // Stored forever
  return profile;
}

The fix: use a bounded cache with an eviction policy. lru-cache is the standard choice:

import { LRUCache } from 'lru-cache';

// Bounded by count and TTL
const cache = new LRUCache<string, UserProfile>({
  max: 1000,                    // Maximum 1000 entries
  ttl: 5 * 60 * 1000,          // Entries expire after 5 minutes
  updateAgeOnGet: false,        // Don't reset TTL on read
});

async function getUserProfile(userId: string) {
  const cached = cache.get(userId);
  if (cached) return cached;

  const profile = await db.getUser(userId);
  cache.set(userId, profile);
  return profile;
}

For anything that needs to survive process restarts or share across instances, move the cache to Redis with a TTL on every key.


2. Event Listeners Never Removed

Every time you call .on() or .addEventListener(), a reference is stored. If you add listeners inside a function that runs repeatedly without removing them, the count grows unboundedly.

Node.js warns you about this — "possible EventEmitter memory leak detected, 11 listeners added" — but the warning is easy to ignore.

// LEAKS — adds a new listener on every request
app.get('/subscribe', (req, res) => {
  emitter.on('data', (data) => {    // Never removed
    res.write(data);
  });
});

Fix: always remove listeners when they are no longer needed.

app.get('/subscribe', (req, res) => {
  const handler = (data: string) => res.write(data);

  emitter.on('data', handler);

  // Remove when the connection closes
  req.on('close', () => {
    emitter.off('data', handler);
    res.end();
  });
});

For one-time listeners, use .once() instead of .on() — it removes itself automatically after firing:

emitter.once('connect', () => {
  // Fires once and self-removes
  console.log('Connected');
});

Detect the problem early:

// Warn loudly if any emitter accumulates too many listeners
import { EventEmitter } from 'events';
EventEmitter.defaultMaxListeners = 15;   // Default is 10 — raise if needed

3. Closures Holding Large Objects

A closure keeps a reference to everything in its lexical scope. If a closure is stored somewhere long-lived and captures a large object, that object cannot be garbage collected even if you think you are done with it.

// LEAKS — the interval closure captures the entire request object
app.post('/process', async (req, res) => {
  const requestData = req.body;   // Could be megabytes

  // This interval holds a reference to requestData forever
  const interval = setInterval(() => {
    checkStatus(requestData.jobId);
  }, 1000);

  res.json({ started: true });
  // interval never cleared — requestData never GC'd
});

Fix: extract only what you need, not the whole object:

app.post('/process', async (req, res) => {
  const jobId = req.body.jobId;   // Only capture the small value needed

  const interval = setInterval(() => {
    checkStatus(jobId);           // No reference to the full request
  }, 1000);

  // Always clear timers when done
  setTimeout(() => clearInterval(interval), 60_000);

  res.json({ started: true });
});

4. Streams Not Properly Closed

If a readable stream is not consumed or destroyed on an error path, it stays open and holds its buffer in memory.

// LEAKS — on error, the stream is never destroyed
async function processFile(filePath: string) {
  const stream = fs.createReadStream(filePath);

  stream.on('data', (chunk) => processChunk(chunk));

  stream.on('error', (err) => {
    console.error('Stream error', err);
    // Stream not destroyed — memory held
  });
}

Fix: always destroy streams on error:

async function processFile(filePath: string) {
  const stream = fs.createReadStream(filePath);

  stream.on('data', (chunk) => processChunk(chunk));

  stream.on('error', (err) => {
    console.error('Stream error', err);
    stream.destroy();    // Release the buffer
  });

  stream.on('close', () => {
    console.log('Stream closed cleanly');
  });
}

// Better: use pipeline which handles cleanup automatically
import { pipeline } from 'stream/promises';

await pipeline(
  fs.createReadStream(filePath),
  async function* (source) {
    for await (const chunk of source) {
      yield processChunk(chunk);
    }
  },
  outputStream
);
// pipeline destroys all streams on error automatically

5. Timers and Promises Never Resolved

Uncleared intervals and timeouts hold references to their callbacks and the closures those callbacks capture. Promises that are created but never resolved or rejected accumulate in memory.

// LEAKS — interval never cleared
function startPolling(id: string) {
  setInterval(async () => {
    await checkStatus(id);
  }, 5000);
  // No way to stop this
}

// LEAKS — promise hangs forever if the event never fires
function waitForEvent(): Promise<string> {
  return new Promise((resolve) => {
    emitter.on('complete', resolve);
    // No timeout — if 'complete' never fires, this hangs forever
  });
}

Fix: always provide a cleanup path and a timeout:

function startPolling(id: string): () => void {
  const interval = setInterval(async () => {
    await checkStatus(id);
  }, 5000);

  // Return a cleanup function
  return () => clearInterval(interval);
}

function waitForEvent(timeoutMs = 30_000): Promise<string> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      emitter.off('complete', handler);
      reject(new Error('Event timeout'));
    }, timeoutMs);

    const handler = (value: string) => {
      clearTimeout(timer);
      resolve(value);
    };

    emitter.once('complete', handler);
  });
}

6. Growing Request/Response Log Objects

If you store request or response objects in an array for debugging or auditing, and that array is never bounded, you accumulate the full HTTP context — headers, body, socket references — for every request that ever hit your app.

// LEAKS — req objects hold socket references and body buffers
const requestLog: express.Request[] = [];

app.use((req, res, next) => {
  requestLog.push(req);    // Grows forever, holds everything
  next();
});

Fix: extract only what you need, bound the storage:

interface RequestSummary {
  id:        string;
  method:    string;
  url:       string;
  timestamp: Date;
}

const recentRequests: RequestSummary[] = [];
const MAX_LOG_SIZE = 1000;

app.use((req, res, next) => {
  recentRequests.push({
    id:        req.id,
    method:    req.method,
    url:       req.originalUrl,
    timestamp: new Date(),
  });

  // Bounded — drop oldest entries when over limit
  if (recentRequests.length > MAX_LOG_SIZE) {
    recentRequests.shift();
  }

  next();
});

Profiling a Leak: The 20-Minute Workflow

When you suspect a leak but do not know which pattern is causing it:

Step 1: Take a baseline heap snapshot

import v8 from 'v8';
import fs from 'fs';

// Add a temporary debug endpoint
app.get('/debug/heap-snapshot', (req, res) => {
  const snapshot = v8.writeHeapSnapshot();
  res.json({ snapshot });
  console.log('Heap snapshot written:', snapshot);
});

Step 2: Simulate load

# Run 1000 requests through the suspected endpoint
npx autocannon -c 10 -d 30 http://localhost:3000/suspect-endpoint

Step 3: Take a second snapshot, then a third

Step 4: Open Chrome DevTools → Memory → Load snapshot → Compare

The objects that grew between snapshot 1 and snapshot 3 are your leak candidates. Look for unexpected counts of request objects, anonymous functions, or application domain objects that should have been short-lived.


The Quick Reference

Leak pattern              Fix
─────────────────────     ──────────────────────────────────────
Unbounded Map/Object      lru-cache with max count and TTL
Event listeners           .off() in cleanup, .once() for one-time
Closures capturing req    Extract only primitive values
Streams on error          stream.destroy() or use pipeline()
Uncleared timers          Return cleanup function, always call it
Storing req objects       Extract summary, bound array size

Most production Node.js memory leaks are one of these six. Find which one yours is, apply the fix, verify the memory graph flattens, and move on.


Reading memoryUsage() Correctly

process.memoryUsage() returns five numbers. Most developers look only at heapUsed and miss the others — which are often where the real signal is.

const mem = process.memoryUsage();
// {
//   rss:          150_994_944,   // Resident Set Size — total memory the process holds in RAM
//   heapTotal:     85_196_800,   // Total heap allocated by V8 (including unused)
//   heapUsed:      62_345_216,   // Heap actually in use by JS objects
//   external:       1_234_567,   // Memory used by C++ objects bound to JS (Buffers, etc.)
//   arrayBuffers:     456_789,   // Subset of external — ArrayBuffer and SharedArrayBuffer
// }

What each one tells you:

heapUsed climbing — JavaScript objects are accumulating. Check the six leak patterns above. This is the most common signal.

rss climbing but heapUsed stable — The V8 heap is healthy but something outside it is growing. Likely causes: Node.js Buffers not being released, a native addon with its own memory management, or the OS not reclaiming pages after a GC cycle (a known V8 behavior on Linux — not always a real leak).

external climbing — Buffers and TypedArray instances backed by native memory are accumulating. Common cause: creating Buffer.alloc() or Buffer.from() in a tight loop without giving them a chance to be GC'd. Also common with streams that are not drained.

heapTotal much larger than heapUsed — V8 allocated heap it is not using. This is usually fine — V8 is conservative about returning memory to the OS. Not a leak signal by itself.

The ratio to watch: if heapUsed / heapTotal is consistently above 85%, you are close to triggering a GC thrash — the garbage collector runs constantly trying to keep up. This is GC pressure, not necessarily a leak, but it degrades performance significantly and is worth addressing.


GC Pressure vs a True Leak: How to Tell the Difference

These look similar on a memory graph but require different fixes.

A true leak: memory climbs indefinitely and never drops, even after traffic stops. Heap snapshots show growing counts of specific object types across captures.

GC pressure: memory climbs under load and drops — but slowly, and not as far as it started. The process is allocating objects faster than GC can collect them. Under sustained load it can look like a leak, but it resolves after traffic eases.

How to distinguish:

# Run your app under load for 2 minutes, then stop all traffic
# Watch heapUsed every 5 seconds

node -e "
setInterval(() => {
  const m = process.memoryUsage();
  console.log(new Date().toISOString(), {
    heapUsed: Math.round(m.heapUsed / 1024 / 1024) + 'MB',
    rss:      Math.round(m.rss / 1024 / 1024) + 'MB',
  });
}, 5000);
"

If heapUsed drops back toward baseline after traffic stops — GC pressure. Fix: reduce allocation rate, pool objects, or increase --max-old-space-size as a short-term mitigation.

If heapUsed stays elevated after traffic stops — true leak. Fix: heap snapshots and the six patterns above.

You can also force a GC cycle manually to confirm:

// Only works when Node.js is started with --expose-gc
if (global.gc) {
  global.gc();
  const after = process.memoryUsage().heapUsed;
  console.log('Heap after forced GC:', Math.round(after / 1024 / 1024) + 'MB');
}

Start your process with node --expose-gc src/server.js. If heap drops significantly after a forced GC, V8 has the objects but has not gotten around to collecting them yet — GC pressure, not a structural leak.


Profiling in Production Safely with --inspect

The heap snapshot approach in the previous section used a debug endpoint that writes files locally. That works in development but is awkward in production containers. The safer production approach is --inspect with port forwarding.

Step 1: Start your app with the inspector enabled but not exposed publicly:

node --inspect=127.0.0.1:9229 src/server.js

127.0.0.1 binds to localhost only — the inspector port is not accessible from outside the container. Never use --inspect=0.0.0.0 in production.

Step 2: Forward the port from your VPS to your local machine:

# From your local machine
ssh -L 9229:127.0.0.1:9229 youruser@your-server-ip -N

This tunnels the remote inspector port to your local machine without exposing it to the internet.

Step 3: Open Chrome DevTools:

  • Navigate to chrome://inspect

  • Click "Configure" and add localhost:9229

  • Your remote Node.js process appears under "Remote Target"

  • Click "inspect"

Step 4: Take heap snapshots under Memory tab:

  • Take snapshot 1 (baseline)

  • Let the suspected endpoint handle traffic for 5-10 minutes

  • Take snapshot 2

  • Take snapshot 3 after another 5 minutes

Step 5: Compare snapshots: In the Memory tab, select "Comparison" view between snapshot 1 and snapshot 3. Sort by # Delta (count difference). Objects with a large positive delta that you did not expect to grow are your leak candidates.

Look for:

  • (closure) — anonymous functions accumulated, usually from the event listener or timer patterns

  • Array — unbounded array growth, usually the request log or cache pattern

  • Your own class names — UserProfile, OrderData, etc. — objects that should be short-lived but are accumulating

  • (string) — sometimes indicates accumulated log strings or cache keys

The combination of the class name and its Retained Size tells you which objects are holding the most memory hostage.


WeakMap and WeakRef: When to Use Them

These exist specifically for cases where you need to associate data with an object but do not want to prevent that object from being garbage collected.

WeakMap — a Map where keys are held weakly. If the key object is no longer referenced anywhere else, the entry is automatically removed by GC.

The right use case: caching derived data about an object without preventing the object from being collected.

// Bad: Map holds strong references to req objects — they cannot be GC'd
const requestMetadata = new Map<express.Request, RequestMeta>();

// Good: WeakMap — when req is done and dereferenced, its metadata is collected too
const requestMetadata = new WeakMap<express.Request, RequestMeta>();

app.use((req, res, next) => {
  requestMetadata.set(req, {
    startTime: Date.now(),
    traceId:   randomUUID(),
  });
  next();
});

Limitations of WeakMap:

  • Keys must be objects, not primitives

  • Not iterable — you cannot loop over a WeakMap or get its size

  • You cannot see what is in it (no .keys(), no .values())

This makes WeakMap unsuitable as a general cache. It is specifically for "private data associated with an object, same lifetime as the object."

WeakRef — a reference to an object that does not prevent GC. You can dereference it with .deref(), which returns the object if it still exists or undefined if it has been collected.

class ImageProcessor {
  private cache = new Map<string, WeakRef<ProcessedImage>>();

  async getProcessed(key: string): Promise<ProcessedImage> {
    const ref = this.cache.get(key);
    const cached = ref?.deref();  // undefined if GC'd

    if (cached) return cached;

    const processed = await this.processImage(key);
    this.cache.set(key, new WeakRef(processed));
    return processed;
  }
}

WeakRef is useful when cached objects are large and you are comfortable letting GC evict them under memory pressure. The cache becomes best-effort rather than guaranteed — entries may disappear. For most caching scenarios lru-cache with a size limit is more predictable. Use WeakRef when the object lifecycle is genuinely managed elsewhere and you just want an opportunistic reference.


The Diagnostic Decision Tree

When your memory graph is trending up and you need to figure out why:

heapUsed climbing?
ā”œā”€ā”€ YES → Take 3 heap snapshots 10 minutes apart
│         │
│         ā”œā”€ā”€ Specific object type growing? → Match to the six patterns
│         │     ā”œā”€ā”€ Closure/Function count growing → Event listener leak (Pattern 2)
│         │     │                                  or Timer leak (Pattern 5)
│         │     ā”œā”€ā”€ Your domain objects growing   → Cache leak (Pattern 1)
│         │     │                                  or Log object leak (Pattern 6)
│         │     └── Buffer/ArrayBuffer growing    → Stream not closed (Pattern 4)
│         │
│         └── No clear object type — random growth?
│               → Check for 3rd party library leaks
│               → Update dependencies, check their issues
│
└── NO → Check external and rss
          │
          ā”œā”€ā”€ external climbing → Buffer leak
          │   → Look for Buffer.alloc() or Buffer.from() without release
          │   → Look for streams not drained/destroyed
          │
          └── rss climbing, heap stable → Likely V8 memory management
                → Force GC and check if rss drops
                → If yes: not a true leak, V8 holding pages
                → If no: native addon, check with node --prof

Things That Look Like Leaks But Are Not

V8 not returning memory to the OS immediately. After a GC cycle, V8 may hold onto freed heap pages rather than releasing them to the OS. RSS stays high even though heapUsed dropped. This is normal V8 behavior on Linux. You can encourage V8 to be more aggressive with --max-semi-space-size tuning or by running v8.setFlagsFromString('--optimize_for_size') on memory-constrained servers.

The initial ramp-up. Node.js processes legitimately use more memory as they warm up — module cache, JIT compilation, connection pools filling. A new process will climb for the first few minutes of traffic and then plateau. Do not diagnose a leak until you have at least 30 minutes of steady traffic on a warmed-up process.

High heapTotal vs heapUsed. V8 allocates heap in chunks. heapTotal is the total allocated; heapUsed is what is actually in use. A ratio of 60-70% is normal. V8 will reclaim unused heap pages over time. This is not a leak.

Third-party connection pools. A database pool that maintains 10 open connections for the lifetime of the process holds memory for those connections continuously. This is expected behavior, not a leak. Compare your baseline memory (app started, no traffic) against your under-load memory. The difference is your working set. The baseline is expected overhead.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design