A Node.js application can work perfectly during development but start consuming more memory after running for several hours. At first, requests may complete normally. Later, the application may become slow, trigger garbage collection more frequently, or eventually crash with an out-of-memory error.
One possible cause is a memory leak.
A memory leak happens when an application keeps references to objects it no longer needs. Because those objects remain reachable, the JavaScript garbage collector cannot reclaim their memory.
Finding the exact source can be difficult in a large application. A growing memory graph tells you that something is wrong, but it does not identify which objects are being retained or why.
Node.js heap snapshots help investigate this problem. They capture information about objects in the V8 JavaScript heap, allowing you to compare memory usage, inspect retained objects, and trace references back to their owners.
In this article, you will learn how to capture heap snapshots, compare them, identify common memory leak patterns, and use the results to fix memory problems safely.
What Is a Memory Leak in Node.js?
Node.js uses the V8 JavaScript engine to manage memory. V8 allocates memory for objects and uses garbage collection to reclaim objects that are no longer reachable.
Consider this example:
const cache = [];
function processRequest(request) {
cache.push({
userId: request.userId,
payload: request.payload
});
}
Every time processRequest() runs, it adds another object to the cache array.
If the application never removes old entries, the array continues to grow. Even if those entries are no longer useful, they remain reachable through the global cache variable.
The garbage collector cannot safely remove them because the application still holds references to them.
This is a common pattern behind memory leaks: objects remain reachable longer than necessary.
However, increasing memory usage does not always mean there is a leak. An application may legitimately need more memory because traffic has increased, a cache is warming up, or a large workload is being processed.
The goal is to identify whether memory growth is expected or whether objects are being retained unnecessarily.
What Is a Heap Snapshot?
A heap snapshot is a representation of objects and references in the V8 JavaScript heap at a particular point in time.
It helps you inspect:
Object types and instance counts.
Memory occupied by JavaScript objects.
Objects retained by other objects.
Reference paths that prevent objects from being collected.
Changes in object counts between snapshots.
A heap snapshot is particularly useful when an application's memory usage continues to increase after repeated operations.
For example, you can capture one snapshot after the application starts, process a representative workload, and capture another snapshot. Comparing them may reveal that a particular class of objects keeps accumulating.
A snapshot does not automatically tell you that every large object is a leak. You must understand why the object remains reachable and whether the application still needs it.
Step 1: Check Node.js Memory Usage
Before capturing snapshots, monitor the application's memory usage.
Node.js provides the process.memoryUsage() method for this purpose.
setInterval(() => {
const memory = process.memoryUsage();
console.log({
rssMB: Math.round(memory.rss / 1024 / 1024),
heapUsedMB: Math.round(memory.heapUsed / 1024 / 1024),
heapTotalMB: Math.round(memory.heapTotal / 1024 / 1024),
externalMB: Math.round(memory.external / 1024 / 1024)
});
}, 10000);
This code reports memory usage every ten seconds.
The important fields are:
rss: Total resident memory used by the process, including JavaScript heap memory and other process memory.
heapUsed: Memory currently used by JavaScript objects in the V8 heap.
heapTotal: Memory allocated by V8 for the JavaScript heap.
external: Memory associated with native resources and objects managed outside the ordinary JavaScript heap, including some buffers.
If heapUsed keeps growing after similar workloads, retained JavaScript objects may be responsible.
If RSS increases while heapUsed remains relatively stable, investigate other possibilities, such as native allocations, buffers, memory fragmentation, or runtime behavior.
Do not diagnose a leak from one measurement. Observe memory over time and compare it with request volume, workload, and garbage collection behavior.
Step 2: Capture a Heap Snapshot with Chrome DevTools
One of the easiest ways to inspect a Node.js heap is through Chrome DevTools.
Start your Node.js application with the inspector enabled:
node --inspect app.js
By default, the inspector listens on a local debugging port, commonly 9229.
Open Chrome and navigate to:
chrome://inspect
Locate your Node.js process and select the option to open its DevTools session.
Then follow these steps:
Open the Memory panel.
Select Heap snapshot.
Click Take snapshot.
Wait for the snapshot to finish.
Save the snapshot if you need to compare it later.
The snapshot provides a view of the objects currently represented in the V8 heap.
Security warning: The Node.js inspector can provide powerful control over the running process. Do not expose it publicly or enable it on an untrusted network. In production, use a controlled debugging environment and restrict access to the inspector port.
Heap snapshots can also temporarily increase memory consumption and pause application execution. Capturing one in a memory-constrained production process can make the situation worse.
Step 3: Compare Heap Snapshots
A single snapshot shows the current heap, but two or more snapshots can reveal which objects are accumulating.
Use a repeatable process:
Start the application with a representative workload.
Allow initial setup and caches to stabilize.
Capture the first heap snapshot.
Send a known number of requests or execute a repeatable job.
Allow temporary objects to become eligible for garbage collection.
Capture another snapshot.
Compare the snapshots in DevTools.
For example, suppose the application processes 5,000 requests between snapshots.
The second snapshot shows:
A large increase in Session objects.
More arrays containing request payloads.
An increase in objects retained by a global cache.
These changes provide useful clues, but they do not prove a leak on their own. The application may legitimately retain sessions or cache entries.
Repeat the workload and capture another snapshot. If the same object counts and retained sizes continue growing without a corresponding application need, investigate those objects more closely.
What to Look for in the Memory Panel
Pay attention to these concepts:
Shallow size: The memory occupied directly by an object itself.
Retained size: The memory that could potentially be released if the object and the objects retained exclusively through it became unreachable.
Retainers: The objects or references that keep a particular object reachable.
Dominators: Objects that dominate other objects in the heap graph, meaning those objects depend on the dominator's reachability along the relevant paths.
Retained size is often more useful than shallow size when investigating a leak. A small object may keep a large collection of other objects alive.
Step 4: Find the Reference Keeping Objects Alive
Consider a session manager that stores every session in a map.
const sessions = new Map();
function createSession(userId, data) {
sessions.set(userId, {
userId,
data,
createdAt: Date.now()
});
}
This code retains every session until the entry is removed.
If sessions are meant to expire, the map needs a cleanup strategy.
For example, you could store an expiration time and remove expired entries during a scheduled cleanup or when the session is accessed.
const sessions = new Map();
function createSession(userId, data) {
sessions.set(userId, {
userId,
data,
expiresAt: Date.now() + 30 * 60 * 1000
});
}
function getSession(userId) {
const session = sessions.get(userId);
if (!session) {
return null;
}
if (session.expiresAt <= Date.now()) {
sessions.delete(userId);
return null;
}
return session;
}
This example removes expired sessions when they are accessed. A production implementation may also need scheduled cleanup so expired entries are removed even when nobody requests them again.
When inspecting a heap snapshot, select an unexpectedly retained object and examine its retaining path.
If the path leads through a global Map, an array, or a long-lived service instance, inspect the code that adds and removes entries from that structure.
The goal is to find the reference that should have been released, not simply to locate the largest object in memory.
Step 5: Identify Common Memory Leak Patterns
Several coding patterns frequently cause unnecessary memory retention in Node.js applications.
1. Unbounded Caches
A cache can improve performance, but an unbounded cache may retain every item ever requested.
const cache = new Map();
function getUserData(userId, data) {
if (!cache.has(userId)) {
cache.set(userId, data);
}
return cache.get(userId);
}
If the number of unique user IDs continues to grow, the map can grow indefinitely.
Use a bounded cache, expiration policy, or eviction strategy when the application does not need to retain every entry permanently.
2. Event Listeners That Are Never Removed
Event listeners can keep references to their callback functions and related objects.
function registerHandler(emitter, user) {
emitter.on("update", () => {
console.log(user.id);
});
}
Each call registers a new listener that captures the user object.
If the listener is no longer needed but remains registered, the captured object may stay reachable.
Use the appropriate listener cleanup method when the associated operation ends.
function registerHandler(emitter, user) {
const handler = () => {
console.log(user.id);
};
emitter.on("update", handler);
return () => {
emitter.off("update", handler);
};
}
The returned cleanup function allows the caller to remove the listener when it is no longer needed.
3. Large Arrays That Keep Growing
Arrays used to collect request data, logs, or intermediate results can become memory problems when entries are never removed.
Check whether old entries can be processed and discarded, whether the array needs a maximum size, or whether the application should stream data instead of accumulating it all in memory.
4. Closures Retaining Large Objects
Closures can keep variables from an outer scope reachable.
This is not inherently a problem. It becomes an issue when a long-lived callback captures large objects that it no longer needs.
Inspect retaining paths to determine whether a callback, timer, or listener unnecessarily keeps those objects alive.
Step 6: Capture a Heap Snapshot from Node.js Code
Node.js also provides the v8 module, which can write heap snapshots programmatically.
const v8 = require("node:v8");
function captureHeapSnapshot() {
const filePath = v8.writeHeapSnapshot();
console.log(`Heap snapshot written to: ${filePath}`);
}
captureHeapSnapshot();
When called, v8.writeHeapSnapshot() writes a snapshot file and returns its filename.
You can open the generated file in Chrome DevTools for inspection.
However, capturing a snapshot is a relatively expensive operation. It can pause the process and require substantial additional memory. A process close to its memory limit may crash while generating one.
Do not automatically capture heap snapshots on every request or at short intervals. Use a controlled trigger, restrict access, and store snapshots securely because they may contain sensitive application data.
Step 7: Verify the Fix
Finding a suspicious object is only the beginning. You need to verify that the change actually stops unnecessary memory growth.
After fixing the suspected leak:
Restart the application in a controlled test environment.
Run the same workload used during diagnosis.
Monitor heapUsed and RSS over time.
Capture new heap snapshots after repeated workloads.
Compare object counts and retained sizes.
Confirm that memory reaches a stable range under comparable conditions.
Memory usage does not need to return to its initial value after every request. V8 may retain allocated heap space for reuse, and legitimate caches may remain populated.
The important result is that unnecessary retained objects stop accumulating and memory stabilizes under a representative workload.
Common Mistakes to Avoid
Assuming every memory increase is a leak: Legitimate caches, traffic growth, and runtime allocation behavior can increase memory usage.
Inspecting only RSS: RSS includes memory outside the JavaScript heap.
Taking only one snapshot: Comparing snapshots after repeatable workloads makes growth easier to identify.
Focusing only on shallow size: Small objects can retain large object graphs.
Ignoring event listeners and timers: Long-lived callbacks can keep objects reachable.
Capturing snapshots carelessly in production: Snapshot generation can pause the application and increase memory pressure.
Fixing the symptom instead of the reference: Increasing the memory limit may delay a crash without removing the leak.
Conclusion
Heap snapshots are a valuable tool for diagnosing memory leaks in Node.js. They help you identify which objects accumulate, how much memory they retain, and which references prevent garbage collection.
Start by monitoring heapUsed and RSS, then capture snapshots before and after a repeatable workload. Compare the results, inspect retaining paths, and look for unbounded caches, forgotten event listeners, growing collections, and unnecessary closure references.
After making a fix, repeat the same test and confirm that memory usage stabilizes. This approach provides stronger evidence than simply increasing the process memory limit or restarting the application when it becomes slow.
Join the conversation! Your thoughts help the community grow.