A Node.js application can run normally for hours and then start using more and more memory until requests become slow or the process crashes. Restarting the application may temporarily fix the problem, but if memory usage keeps growing after every restart, there may be a memory leak.
Memory leaks are not always caused by a single obvious mistake. An application may retain objects in a global cache, keep references to completed requests, register event listeners repeatedly, or leave timers running after their work is finished. These objects remain in memory because the garbage collector cannot safely remove them while references still exist.
Node.js provides several ways to investigate memory usage. Heap snapshots are particularly useful because they show the JavaScript objects retained in the V8 heap and the references that keep those objects alive.
This article explains how to identify a potential memory leak, capture heap snapshots, compare snapshots, and use the results to locate the code responsible for retaining memory.
What Is a Memory Leak in Node.js?
Node.js uses the V8 JavaScript engine, which automatically manages memory for JavaScript objects. When an object is no longer reachable through references from active program state, the garbage collector can reclaim its memory.
A memory leak occurs when an application retains objects that it no longer needs. The garbage collector cannot remove those objects because they are still reachable.
Consider this example:
const requestHistory = [];
function handleRequest(request) {
requestHistory.push({
url: request.url,
headers: request.headers,
receivedAt: Date.now()
});
}
Every call to handleRequest adds another object to the array. If the application never removes these entries, the array continues to grow.
If request.headers contains many values, the application may retain substantially more memory than expected.
The problem is not that JavaScript failed to collect garbage. The objects remain reachable through requestHistory, so the garbage collector must preserve them.
A memory leak can develop in several places:
Global arrays, maps, and caches that grow without limits.
Event listeners that remain registered after they are no longer needed.
Timers and intervals that continue running unnecessarily.
Closures that retain large objects.
Request handlers that store request-specific data in long-lived objects.
Background jobs that accumulate pending work.
The first step is to determine whether memory usage is genuinely increasing over time.
Step 1: Monitor Node.js Memory Usage
Before capturing a heap snapshot, collect basic memory statistics. Node.js exposes this information through process.memoryUsage().
function logMemoryUsage() {
const memory = process.memoryUsage();
console.log({
rssMB: (memory.rss / 1024 / 1024).toFixed(2),
heapUsedMB: (memory.heapUsed / 1024 / 1024).toFixed(2),
heapTotalMB: (memory.heapTotal / 1024 / 1024).toFixed(2),
externalMB: (memory.external / 1024 / 1024).toFixed(2),
arrayBuffersMB: (
memory.arrayBuffers / 1024 / 1024
).toFixed(2)
});
}
setInterval(logMemoryUsage, 30000);
This example logs memory statistics every 30 seconds. In a production application, use your existing monitoring system rather than adding an unbounded amount of console output.
The fields provide different views of memory usage:
rss represents the resident memory occupied by the process, including JavaScript heap, native allocations, and other process memory.
heapUsed represents the JavaScript heap currently in use.
heapTotal represents the heap memory V8 has allocated.
external represents memory associated with native objects managed through JavaScript objects.
arrayBuffers reports memory used by ArrayBuffer and related buffers, including Node.js buffer allocations.
A growing RSS value does not necessarily mean the JavaScript heap has a leak. Native allocations, buffers, memory fragmentation, and other factors can affect process memory.
Similarly, heapUsed may rise and fall as the garbage collector runs. Look for a sustained increase rather than assuming that every temporary increase is a leak.
Step 2: Create a Reproducible Test
A useful memory investigation needs a repeatable workload.
For example, suppose an API normally handles a few requests per second but begins using more memory during a traffic spike. Reproduce the same request pattern in a test environment and monitor memory before, during, and after the workload.
The investigation should answer three questions:
Does memory usage continue increasing as requests are processed?
Does memory return to a relatively stable level after the workload stops and garbage collection occurs?
Which objects remain in memory after they should no longer be needed?
If memory increases during a workload but returns to a stable level afterward, the behavior may be normal allocation and garbage collection rather than a leak.
If memory remains high after garbage collection and continues growing with repeated workloads, retained objects deserve closer inspection.
Do not force garbage collection in a production application simply to make a monitoring graph look better. If you need controlled garbage collection for diagnosis, use an isolated test environment or a carefully planned diagnostic session.
Step 3: Capture a Heap Snapshot
A heap snapshot records the JavaScript heap at a particular moment. It allows you to inspect object types, retained sizes, references, and other information that helps explain why memory remains in use.
Node.js supports heap snapshots through the V8 inspector and diagnostic APIs.
One practical approach is to start the application with the inspector enabled:
node --inspect app.js
The inspector listens on a debugging endpoint. Connect to it using the Chrome DevTools interface and open the Memory panel.
Choose the heap snapshot option and capture a snapshot while the application is running.
For comparison, capture another snapshot after the application has processed a similar workload.
Be careful when enabling the inspector. It provides powerful debugging capabilities and should not be exposed to untrusted networks. In production, restrict access and use a controlled diagnostic procedure.
Capture a snapshot through the Node.js API
Node.js also provides the v8.writeHeapSnapshot() function.
const v8 = require('node:v8');
const filename = v8.writeHeapSnapshot();
console.log(`Heap snapshot written to ${filename}`);
Calling this function writes a heap snapshot to a file and returns its filename.
This approach is straightforward, but taking a snapshot can temporarily require substantial memory and pause application work. On a large process, the operation may affect latency or cause the process to run out of memory.
Capture snapshots in a test environment first. If production diagnosis is necessary, consider the process size, available memory, traffic, and operational impact before proceeding.
Step 4: Compare Heap Snapshots
A single snapshot shows what is in memory at one moment. Comparing two snapshots helps reveal which objects accumulated between captures.
For example:
Start the application and capture the first snapshot.
Send a known number of requests.
Allow temporary work to finish.
Capture a second snapshot.
Compare the snapshots in DevTools.
In the Memory panel, use the comparison view to examine changes in object counts and retained sizes.
Look for objects whose counts or retained memory increase significantly between snapshots.
Common candidates include:
Arrays containing request or event data.
Maps holding cached responses.
Objects representing completed jobs.
Repeatedly registered listeners.
Closures retaining request-specific variables.
A large object is not automatically a leak. It may be a legitimate cache or application data structure. The important question is whether the object should still be retained and whether its growth is bounded.
Understand shallow size and retained size
Heap analysis tools report different measurements.
Shallow size represents the memory occupied directly by an object. Retained size estimates the memory that could become reclaimable if that object and the references keeping its associated objects reachable were removed.
An array may have a relatively small shallow size but retain a large number of objects through its entries.
For that reason, examining retained size and reference paths is often more useful than sorting objects only by shallow size.
Step 5: Find the References Keeping Objects Alive
After identifying a suspicious object, inspect its retaining path in the heap snapshot.
The retaining path shows the references that make an object reachable from a root, such as a global variable or another long-lived object.
Consider this example:
const completedRequests = new Map();
function saveRequestResult(requestId, result) {
completedRequests.set(requestId, result);
}
If the application stores every result indefinitely, the map continues growing.
A heap snapshot may show a reference path from a global variable to the map and then to the stored result objects.
This points to a specific area of code that needs review.
The correct fix depends on the purpose of the data. You might remove entries after processing, impose a size limit, add an expiration policy, or avoid retaining the complete result.
For example, if the application only needs a small amount of recent request history, it can use a bounded collection:
const completedRequests = new Map();
const MAX_ENTRIES = 1000;
function saveRequestResult(requestId, result) {
completedRequests.set(requestId, result);
if (completedRequests.size > MAX_ENTRIES) {
const oldestKey =
completedRequests.keys().next().value;
if (oldestKey !== undefined) {
completedRequests.delete(oldestKey);
}
}
}
This example removes the oldest insertion when the map exceeds its limit. It is suitable only when that eviction behavior matches the application's requirements. Production caches may need expiration, concurrency considerations, or a dedicated cache implementation.
The key is to understand why an object is retained before changing the code.
Step 6: Investigate Common Sources of Memory Leaks
Unbounded caches
A cache that stores every result without expiration or eviction can consume more memory as traffic grows.
Use an explicit size limit, time-to-live policy, or a cache implementation that supports bounded storage. Monitor cache size and hit rate to make sure the cache remains useful.
Event listeners that are never removed
Registering listeners repeatedly can keep objects alive longer than expected.
function startMonitoring(emitter) {
const onData = (data) => {
processData(data);
};
emitter.on('data', onData);
}
If startMonitoring is called repeatedly and the listener is never removed, the emitter retains every registered callback.
When the listener is no longer needed, remove it:
function startMonitoring(emitter) {
const onData = (data) => {
processData(data);
};
emitter.on('data', onData);
return () => {
emitter.off('data', onData);
};
}
The returned function allows the caller to clean up the listener when monitoring ends. The caller must actually invoke that function at the appropriate lifecycle boundary.
Timers that continue running
A timer can keep callbacks and referenced objects alive.
function startPolling(service) {
const timer = setInterval(() => {
service.refresh();
}, 5000);
return () => clearInterval(timer);
}
The cleanup function stops the interval when polling is no longer required.
Closures retaining large objects
A callback may retain variables from its surrounding scope. If the callback is stored in a long-lived object, those variables can remain reachable even when the original operation has completed.
Inspect retaining paths to identify the references responsible. Avoid capturing large objects when only a small value is needed.
Step 7: Fix the Leak and Verify the Result
Once the source of retention is identified, make a targeted change.
For an unbounded collection, define an appropriate eviction policy. For listeners and timers, add lifecycle cleanup. For request-specific data, avoid storing it in global state unless it genuinely needs to outlive the request.
Then repeat the original workload.
A useful verification process is:
Capture a baseline snapshot.
Run the same workload that exposed the problem.
Allow temporary operations to finish.
Capture another snapshot.
Compare object counts and retained sizes.
Repeat the workload to check whether memory continues growing.
A successful fix should reduce unintended retention or establish a stable memory pattern under repeated workloads.
Do not expect RSS to return immediately to its initial value. V8 and the operating system may retain allocated memory for reuse. Evaluate the heap, retained objects, process memory, and workload behavior together.
Common Mistakes to Avoid
Treating every increase in RSS as proof of a JavaScript heap leak.
Capturing only one snapshot and assuming it identifies the cause.
Taking heap snapshots in production without considering memory and latency impact.
Fixing a large object without checking whether it is legitimately required.
Removing event listeners without keeping the original callback reference.
Assuming that restarting the process fixes the underlying problem.
Ignoring native memory when heap usage remains stable but RSS continues growing.
Making changes without repeating the same workload to verify the result.
Best Practices for Node.js Memory Diagnostics
Monitor heap usage and RSS separately.
Reproduce memory growth under a controlled workload.
Compare heap snapshots captured at meaningful points in the application's lifecycle.
Inspect retaining paths rather than focusing only on object size.
Review global collections, caches, listeners, timers, and closures.
Bound caches and define cleanup behavior for long-lived resources.
Restrict access to debugging interfaces.
Measure the effect of heap snapshots before using them on production processes.
Repeat the workload after fixing the issue and compare the results.
Include memory trends in application monitoring so abnormal growth is detected early.
Summary
Heap snapshots provide a practical way to investigate memory leaks in Node.js. They show which JavaScript objects remain in memory and help trace the references that prevent those objects from being collected.
Start by monitoring process.memoryUsage(), reproduce the growth, and capture snapshots before and after a representative workload. Compare the snapshots, inspect retaining paths, and identify whether the application is holding onto data through caches, collections, listeners, timers, or closures.
After making a targeted fix, repeat the same workload and verify that retained memory no longer grows unexpectedly. Combining heap snapshots with ongoing memory monitoring helps teams diagnose leaks before they cause serious performance problems or process failures.
Join the conversation! Your thoughts help the community grow.