A Node.js application may work correctly during startup and initial testing but become slower after running for several hours. Memory usage gradually increases, garbage collection runs more frequently, and eventually the process may consume too much memory or terminate with an out-of-memory error.
These symptoms can indicate a memory leak.
JavaScript uses garbage collection to reclaim memory that an application can no longer reach. However, garbage collection cannot remove objects that are still referenced, even when the application no longer needs them. A growing cache, an event listener that is never removed, or an array that keeps accumulating request data can cause this problem.
Heap snapshots help developers investigate these issues by showing which JavaScript objects occupy memory and how those objects remain reachable. With the right comparison, you can identify objects that keep accumulating and trace them back to the code responsible.
This article explains how to capture Node.js heap snapshots, compare them, identify common memory leaks, and investigate problems without putting a production process at unnecessary risk.
What Is a Heap Snapshot?
A heap snapshot is a representation of the JavaScript heap at a particular point in time. It contains information about allocated objects, their sizes, and the references connecting them.
You can inspect a snapshot using Chrome DevTools. The Memory panel provides views that help you find objects consuming memory and understand why those objects remain reachable.
A heap snapshot can help answer questions such as:
Which object types occupy the most memory?
Are arrays, maps, or application objects growing over time?
Which references prevent objects from being garbage-collected?
Does a particular object remain in memory after a request has completed?
A snapshot is not a complete record of every kind of memory used by Node.js. Native allocations, buffers, external memory, and memory managed outside the JavaScript heap may require additional investigation.
It is also important to distinguish a memory leak from normal memory growth. A service may legitimately retain frequently used data in a bounded cache. A leak occurs when memory remains allocated or continues to grow without a valid reason.
Step 1: Check Memory Usage in Node.js
Before capturing a heap snapshot, measure the application's memory usage.
Node.js provides the process.memoryUsage() method, which reports several memory metrics.
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)
});
The output includes several useful metrics.
rss represents the resident memory used by the process, including JavaScript heap memory, native allocations, and other memory mapped into the process.
heapUsed reports the portion of the V8 JavaScript heap currently occupied by objects.
heapTotal represents the heap memory V8 has allocated for JavaScript objects.
external reports memory associated with native objects that is managed outside the JavaScript heap.
arrayBuffers reports memory associated with ArrayBuffer and SharedArrayBuffer allocations, including relevant Node.js buffer allocations.
If heapUsed keeps increasing after the application has handled similar workloads, JavaScript objects may be accumulating. If RSS grows while heapUsed remains relatively stable, investigate native memory, buffers, fragmentation, and other process-level allocations as well.
One measurement is rarely enough. Record memory usage over time and compare it under similar workloads.
Step 2: Capture a Heap Snapshot
Node.js provides a built-in V8 heap profiling interface through the v8 module. You can use it to write a heap snapshot to a file.
const v8 = require('node:v8');
const path = require('node:path');
function writeHeapSnapshot() {
const fileName = `heap-${Date.now()}.heapsnapshot`;
const filePath = path.join(process.cwd(), fileName);
const snapshotPath = v8.writeHeapSnapshot(filePath);
console.log(`Heap snapshot written to: ${snapshotPath}`);
}
writeHeapSnapshot();
This example creates a timestamped snapshot in the current working directory.
You can use a similar function during controlled debugging, but do not expose an unauthenticated HTTP endpoint that lets anyone request a heap snapshot. Snapshot files can contain sensitive application data, including strings, request-related information, and other objects retained in memory.
Heap snapshot generation can also consume significant memory and pause application execution. Capture snapshots in a test environment or controlled diagnostic session whenever possible.
For production investigations, assess the memory limits and operational impact before capturing a snapshot. A process already close to its memory limit may fail while generating one.
Step 3: Open the Snapshot in Chrome DevTools
Once you have a .heapsnapshot file, open Chrome DevTools.
Open Chrome DevTools.
Select the Memory panel.
Choose the option to load a heap snapshot.
Select the snapshot file generated by Node.js.
DevTools analyzes the snapshot and displays the objects represented in it.
The interface includes several views. The Summary view groups objects by constructor and shows their memory usage. The Comparison view helps identify differences between snapshots. The Containment view shows object relationships and can help you trace references.
A useful investigation starts with objects that are unexpectedly numerous or large. A large number of objects is not automatically a problem, so compare the results with what the application should retain.
For example, thousands of request objects may be normal in a short-lived snapshot but suspicious if completed requests remain reachable long after processing finishes.
Step 4: Capture and Compare Multiple Snapshots
A single snapshot shows the current state of memory. Comparing snapshots helps reveal which objects accumulate as the application handles work.
Use this process:
Start the application with a representative workload.
Allow startup activity and temporary allocations to settle.
Capture the first snapshot.
Run a repeatable workload, such as processing a fixed number of requests.
Capture another snapshot.
Repeat the workload and capture a third snapshot.
Compare the snapshots in DevTools.
The Comparison view can show changes in object counts and retained sizes between snapshots.
Look for object types whose counts or retained sizes continue to increase across repeated workloads. A growing count of application-specific objects may indicate that references are being retained unexpectedly.
However, memory growth between two snapshots is not proof of a leak. The application may have warmed a cache, initialized a connection pool, or retained data intentionally. Repeat the experiment and determine whether the growth stabilizes.
Where appropriate, trigger garbage collection in a controlled debugging session before taking comparison snapshots. This can help distinguish temporary allocations from objects that remain reachable. Do not assume that forcing garbage collection is a normal production solution.
Step 5: Understand Retained Size and Retainers
When investigating a suspicious object, two concepts are particularly useful: retained size and retainers.
The shallow size of an object is the memory occupied by the object itself. Its retained size estimates the memory that could become collectible if that object and the objects reachable only through it were released.
A retainer is a reference or chain of references that keeps an object reachable.
For example, a global map may retain thousands of request objects. Each request object may reference additional data, such as parsed JSON, headers, or intermediate results.
The request objects may look small individually, but the entire retained object graph can consume substantial memory.
In DevTools, select a suspicious object and inspect its retaining path. Follow the references back toward a long-lived root, such as a global variable, module-level collection, or active callback.
The goal is to find the code that keeps the object reachable and determine whether that reference is still required.
Step 6: Reproduce a Common Memory Leak
Consider an application that stores processed requests in a global array for debugging.
const processedRequests = [];
function processRequest(request) {
const result = {
id: request.id,
payload: request.body,
processedAt: Date.now()
};
processedRequests.push(result);
return {
success: true,
id: result.id
};
}
module.exports = { processRequest };
The function returns a small response, but the global array retains every processed request.
If the application handles thousands of requests, the array continues to grow. Because the array remains reachable from a module-level variable, garbage collection cannot reclaim its entries.
The fix depends on why the data is being retained. If the array exists only for debugging, remove it from production code. If recent request history is required, enforce a retention limit.
For example:
const processedRequests = [];
const MAX_HISTORY = 1000;
function processRequest(request) {
const result = {
id: request.id,
processedAt: Date.now()
};
processedRequests.push(result);
if (processedRequests.length > MAX_HISTORY) {
processedRequests.splice(
0,
processedRequests.length - MAX_HISTORY
);
}
return {
success: true,
id: result.id
};
}
module.exports = { processRequest };
This version retains only the most recent 1,000 entries.
A bounded collection is useful when historical data is genuinely needed. However, the limit must fit the application's memory budget, and the stored objects should not contain large payloads unless necessary.
Step 7: Investigate Event Listener Leaks
Event listeners can also retain objects longer than expected.
Consider this example:
const EventEmitter = require('node:events');
const emitter = new EventEmitter();
function handleRequest(request) {
const requestData = request.body;
emitter.on('completed', () => {
console.log(requestData);
});
}
Every call to handleRequest registers another listener. Each listener closes over requestData, keeping that data reachable.
If the listeners are never removed and the event emitter remains alive, the retained objects can accumulate.
If the listener is needed only once, use once():
function handleRequest(request) {
const requestData = request.body;
emitter.once('completed', () => {
console.log(requestData);
});
}
This removes the listener after it handles the event once. It does not solve every possible retention problem, especially if the event never occurs. In that case, the application needs a suitable cleanup strategy when the request finishes, fails, or is cancelled.
When a snapshot shows many listener functions or closures, inspect their retaining paths and determine whether they belong to active operations or completed work.
Other Causes of Node.js Memory Growth
Not every memory problem comes from a global array or event listener. Several other patterns deserve attention.
Unbounded caches can retain every key and value ever requested. Use size limits, expiration rules, or a cache implementation designed for the workload.
Long-running timers can retain closures and objects. Clear timers when their work is complete, and ensure recurring tasks have a defined lifecycle.
Unresolved promises and background tasks can keep references alive. Review operations that wait indefinitely for external services, streams, or events.
Large buffers may increase RSS and external memory without producing a similar increase in heapUsed. Investigate the external and arrayBuffers metrics when that happens.
Native modules can allocate memory outside the JavaScript heap. A heap snapshot may not explain all of this growth, so use operating-system metrics and native memory profiling tools when needed.
Common Mistakes During Heap Analysis
One common mistake is assuming that a high heap usage value always indicates a leak. V8 may keep allocated heap space available for later use, and an application may legitimately retain frequently accessed data.
Another mistake is comparing snapshots taken under different workloads. A snapshot after startup cannot always be compared meaningfully with one captured after hours of traffic. Use repeatable workloads and consistent measurement points.
Avoid relying only on object counts. A small number of objects can retain large object graphs, while many small short-lived objects may be normal. Inspect retained size and retaining paths.
Finally, do not capture heap snapshots casually on a heavily loaded production process. The operation can pause execution and increase memory pressure. Use controlled reproduction whenever possible and protect diagnostic files as sensitive data.
Best Practices for Preventing Memory Leaks
Track memory usage over time instead of relying on a single measurement. Monitor RSS, heap usage, external memory, and application-specific metrics under representative workloads.
Set explicit limits for in-memory caches and collections. Remove event listeners and timers when their work is complete, and make sure background tasks have clear cancellation and cleanup behavior.
Use heap snapshots when memory growth cannot be explained by ordinary application activity. Compare multiple snapshots, identify objects that accumulate, and follow retaining paths back to the responsible code.
Include long-running tests in your development process. A service that passes a short functional test may still leak memory after thousands of requests or repeated connection cycles.
Conclusion
Heap snapshots provide a practical way to investigate memory leaks in Node.js applications. They show which JavaScript objects occupy memory, how their counts change between snapshots, and which references keep them alive.
Start by measuring memory usage, reproduce the problem with a consistent workload, and capture multiple snapshots. Use the Comparison view to identify growing objects, then inspect their retaining paths to locate the code responsible.
The most effective fix is usually to correct the reference lifecycle, bound retained data, or clean up listeners and background work. Increasing the memory limit may delay an out-of-memory failure, but it does not solve a leak that continues to grow without a valid limit.
Join the conversation! Your thoughts help the community grow.