TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

How to Debug a Memory Leak in a Node.js Application

Practical insights, clear explanations, and useful ideas for understanding the web.

October 25, 20269 min read5 topics
How to Debug a Memory Leak in a Node.js Application
Explore the details
#Node.js#memory leaks#performance#heap snapshots#debugging
October 25, 20269 min read

A Node.js service can become slower, trigger more garbage collection, or eventually run out of memory even when its request volume looks normal. The cause may be a true JavaScript heap leak, an unbounded cache, references held by listeners or timers, or memory allocated outside the JavaScript heap. Knowing which kind of growth you are seeing helps you investigate without mistaking normal runtime behavior for a leak.

This guide walks through a repeatable way to debug a Node.js memory leak: collect baseline metrics, reproduce the growth, compare heap snapshots, and trace objects back to the references keeping them alive. It also covers ways to investigate safely when profiling could affect a running service.

Key takeaways

  • Look for memory that keeps growing under a repeatable workload, especially after garbage collection has had a chance to run.
  • Compare heapUsed with RSS, external, and arrayBuffers to distinguish JavaScript heap growth from other memory use.
  • Take comparable heap snapshots and inspect both growing object types and their retaining paths.
  • Reproduce the issue in a controlled environment before using intrusive diagnostics on production.
  • Fix the reference or allocation that causes unbounded growth; restarting a process only resets the symptom.

1. Confirm that memory is actually leaking

Memory use naturally rises and falls. JavaScript objects that are no longer reachable can be reclaimed by garbage collection, but collection does not have to happen immediately. A temporary rise in memory, by itself, does not prove there is a leak.

Start by collecting metrics over time while recording what the application is doing. Node.js exposes several useful values through process.memoryUsage():

  • heapUsed: JavaScript heap currently in use.
  • heapTotal: memory allocated for the JavaScript heap.
  • rss: resident memory for the process, including JavaScript and native allocations.
  • external: memory associated with C++ objects bound to JavaScript objects.
  • arrayBuffers: memory used by ArrayBuffer and SharedArrayBuffer, including Node.js Buffer instances.

A simple diagnostic logger can help reveal trends:

const formatMiB = (bytes) => (bytes / 1024 / 1024).toFixed(1);

const interval = setInterval(() => {
  const memory = process.memoryUsage();

  console.log({
    timestamp: new Date().toISOString(),
    rssMiB: formatMiB(memory.rss),
    heapUsedMiB: formatMiB(memory.heapUsed),
    heapTotalMiB: formatMiB(memory.heapTotal),
    externalMiB: formatMiB(memory.external),
    arrayBuffersMiB: formatMiB(memory.arrayBuffers),
  });
}, 10_000);

// In a short-lived diagnostic script, stop the logger when appropriate:
// clearInterval(interval);

Use the same units and sampling interval throughout a test. Compare readings at similar points in the workload, such as after a batch of requests has completed. If heapUsed repeatedly climbs and does not settle as the same workload is repeated, investigate JavaScript references. If RSS or external grows while heapUsed is relatively stable, check native allocations, buffers, and dependencies that manage memory outside the ordinary JavaScript heap.

These metrics are clues, not a diagnosis. RSS can remain high after garbage collection because the runtime may retain allocated memory for reuse. Conversely, a stable RSS reading does not prove that every part of the application is healthy.

2. Reproduce the growth with a controlled workload

A useful reproduction makes the memory trend repeatable and narrows down what triggers it.

  1. Record the environment. Note the Node.js version, operating system, application configuration, and relevant dependency versions.
  2. Start from a known baseline. Use a fresh process and allow normal startup work to finish before measuring.
  3. Run one representative workload. Keep request mix, concurrency, input sizes, and test duration consistent.
  4. Repeat the workload. Track memory after each batch rather than relying on one before-and-after measurement.
  5. Change one factor at a time. For example, compare the endpoint with and without a particular feature or background task.
  6. Check after workload stops. Continued growth while idle may point to timers, queues, subscriptions, or background work that is still retaining data.

Use synthetic or sanitized data when possible. Keep a reproduction isolated from real user data and external systems unless testing there is explicitly authorized. A stress test that uses unrealistically large inputs can expose capacity limits, but it may not represent the behavior causing a production leak.

3. Capture and compare heap snapshots

A heap snapshot records objects in a V8 isolate and the references between them. Comparing snapshots can show which object types accumulated and why those objects remain reachable.

For a useful comparison, take one snapshot after startup and warm-up, run a fixed workload, then take another snapshot under comparable conditions. If practical, repeat the workload and take a third snapshot. A type that grows across comparable snapshots is a stronger lead than an object that appears only once.

Node.js documents ways to capture snapshots, including the V8 API and diagnostic workflows. For example, a short-lived diagnostic script can write a snapshot using the node:v8 module:

const v8 = require('node:v8');

const filename = v8.writeHeapSnapshot();
console.log(`Heap snapshot written to ${filename}`);

Open the resulting file in a compatible Chromium-based DevTools Memory panel. Compare snapshots, inspect objects that have increased, and follow their retaining paths. A retaining path is the chain of references that keeps an object reachable. The important question is not only “What object is large?” but also “Which long-lived object is keeping it alive, and should that reference still exist?”

Heap snapshots have significant operational trade-offs. Creating one can pause the event loop, consume substantial CPU and disk I/O, and require additional memory; on a memory-constrained process, the operation can contribute to an out-of-memory failure. Snapshot files can also contain application data and should be handled as sensitive artifacts. Prefer a staging environment or a production-like replica. If production capture is unavoidable, assess capacity and latency risk, follow your incident procedures, restrict access to the output, and verify available disk space before capturing. Do not assume a snapshot is a low-impact observability check.

4. Read retaining paths, not just object counts

A snapshot may show more strings, arrays, closures, or instances of an application class. That is a starting point, not proof of a bug. Inspect the retaining path to identify the owner of the reference and determine whether its lifetime is expected.

Common patterns include:

Event listeners that accumulate

Registering a listener repeatedly without removing it can retain its callback and anything the callback closes over. For example, attaching a new listener for every request to a long-lived emitter can cause listener counts and retained request data to grow.

Pair listener registration with removal when the work completes, or use a one-time listener when that matches the intended behavior. Be careful with shared emitters: removing all listeners can break unrelated parts of an application. Remove only the listener your code owns.

Timers and background tasks

An interval that is never cleared can keep its callback and captured variables alive. The same can happen with scheduled retries, polling loops, and background jobs that retain completed task data. Ensure that timers are stopped when their owner is disposed, and check that recurring work does not enqueue faster than it can finish.

Unbounded caches and collections

A Map, array, or object used as a cache retains entries as long as the collection retains them. Add an explicit size bound or an eviction policy appropriate to the data. A time-to-live policy alone may not limit memory if expired entries are not removed promptly. Consider the size of cached values as well as the number of keys.

Closures and module-level state

Closures can keep outer variables reachable, and module-level collections can live for the entire process. This is often intentional. It becomes a problem when request-specific data, large buffers, or obsolete objects are stored in long-lived state without a clear lifetime or cleanup path.

5. Distinguish heap growth from external memory

If heap snapshots do not explain the trend, compare the memory metrics again. JavaScript wrappers can refer to allocations managed by native code, so a relatively stable JavaScript heap does not rule out process memory growth.

Inspect code and dependencies that use buffers, native addons, database drivers, image or compression libraries, and other native resources. Check whether resources have documented close, dispose, or release operations, and whether error paths call them too. A growing arrayBuffers value suggests looking closely at buffers and typed arrays, but it does not identify the owner by itself.

Also review runtime and container limits. A process can be constrained by a container memory limit even when its JavaScript heap has not reached a configured V8 limit. Use the metrics available in your deployment environment alongside Node.js metrics, and avoid treating any single value as a complete measure of memory health.

6. Common mistakes and troubleshooting tips

  • Calling every memory increase a leak: Repeat the same workload and examine the trend after work completes.
  • Comparing unlike snapshots: Match startup state, workload, and timing as closely as possible.
  • Focusing only on the largest object: A small object retained thousands of times may matter more than one large, expected object.
  • Confusing heapUsed with total process memory: Check RSS and external-memory metrics too.
  • Forcing garbage collection as a production fix: It may change what you observe, but it does not remove references that keep objects reachable. If you use explicit GC for a controlled diagnostic comparison, treat it as an experimental aid, not an application-level leak fix.
  • Capturing snapshots without capacity planning: A capture can affect latency and memory, and the output may contain sensitive data.
  • Fixing symptoms with restarts alone: Restarts can restore service temporarily, but they do not identify or correct the source of growth.

If the leak is intermittent, add lightweight, targeted measurements around the suspected component: queue lengths, cache entry counts, listener counts, active jobs, or buffer sizes. Keep labels bounded and avoid logging sensitive payloads. A small set of measurements can help connect a memory trend to a specific operation before you perform a more intrusive capture.

Defensive investigation practices

  • Reproduce memory growth in staging or an isolated environment before profiling production.
  • Set limits for caches, queues, uploads, and concurrent work; define what happens when each limit is reached.
  • Give listeners, timers, subscriptions, and native resources explicit owners and cleanup paths.
  • Protect heap snapshots and diagnostic logs as potentially sensitive data, and delete them according to your retention policy.
  • If production diagnostics are necessary, estimate the possible pause and resource impact, use an approved incident procedure, and monitor the service during the operation.
  • Validate a suspected fix by running the same reproduction and confirming that memory stabilizes across repeated workload cycles.

Conclusion

To debug a Node.js memory leak, first establish a repeatable memory trend, then identify whether it involves the JavaScript heap or other process memory. Use comparable heap snapshots to find growing objects and follow retaining paths to the references that keep them alive. Check listeners, timers, caches, queues, closures, and native resources, then verify the fix with the same controlled workload. Treat profiling as an operation with real resource and data-handling risks, especially in production.

References