Node.js Memory Leaks: How to Find and Fix Using Heap Snapshots
Memory leaks are among the most frustrating issues in long-running Node.js applications — especially in SSR (Server-Side Rendering) environments like Angular Universal. You deploy your app, everything seems fine, and then memory usage slowly creeps up until the application container restarts or crashes.
At Halodoc, our Angular Universal application serves millions of users and this challenge is one we've had to solve to ensure stability and performance. In this post, we’ll walk through the exact process we used at Halodoc to detect, capture, and analyze Node.js memory leaks using heap snapshots — the most powerful debugging tool for understanding what’s consuming your memory over time.
The Problem: Gradual Memory Growth
You might have noticed this pattern:
- Your app’s memory usage keeps increasing after each request.
- Eventually, the application container restarts or becomes unresponsive.
This usually points to memory leaks... But where are they coming from? Closures, caches, unremoved event listeners, or large response buffers? Finding the source by just reading code is nearly impossible; you can't see what's being held in memory. You only know memory is growing, not what is growing.
That’s where heap snapshots come in.
What Is a Heap Snapshot?
A heap snapshot is a detailed dump of the JavaScript memory heap at a given moment.
It shows you:
- All objects in memory
- Their relationships (who references whom)
- Sizes (shallow and retained)
- Retainers and dominator trees — to trace why objects are not being garbage collected
In short, it’s a microscope into your app’s memory.
A Quick Note on Object Sizes
When you look at a heap snapshot, you'll see two key size metrics:
- Shallow Size: This is the memory occupied by the object itself. For example, the shallow size of an array is the memory needed for the array's structure, not the objects it contains.
- Retained Size: This is the full amount of memory that would be freed if this object were deleted. It includes the object's shallow size plus the size of all other objects that are only referenced by it.
For finding memory leaks, Retained Size is the most critical metric. A high retained size means an object is preventing a large chunk of memory from being garbage collected. When analyzing leaks, always prioritize deltas (changes) in size across snapshots; shallow size can be misleading.
How to Take Heap Snapshots in Node.js
There are three main ways to take heap snapshots:
1. Using the Node.js Inspector
This can be used for local development. Simply start your Node app with the --inspect flag:
node --inspect server.jsThen, open Chrome and navigate to:
chrome://inspectClick "Open dedicated DevTools for Node", go to the Memory tab, and capture a heap snapshot.
2. Using the heapdump Module
For production environments, where you can’t attach a debugger:
npm install heapdumpThen in your code:
import heapdump from 'heapdump';
heapdump.writeSnapshot(`/tmp/heap-${Date.now()}.heapsnapshot`);This writes the snapshot file to the server's local filesystem in the path passed to the writeSnapshot function. To analyze it, you would then need to manually copy the file from your server to your local machine using a tool like scp or sftp.
While this does the work, it's not ideal, as it consumes server storage and requires manual access. (I will explain our automated, ideal way to store the snapshots in the 'Snapshot Storage Strategy' section).
Once you have the file locally, you can open it in Chrome DevTools → Memory Tab → Load Snapshot
3. Using the Built-in Node.js V8 API
From Node.js v12+, you can use the built-in V8 module to programmatically capture a snapshot — no external packages needed:
import { writeHeapSnapshot } from 'node:v8';
// Writes a .heapsnapshot file to the current working directory
writeHeapSnapshot();By default, it saves a filename in this format:
Heap.${yyyymmdd}.${hhmmss}.${pid}.${tid}.${seq}.heapsnapshotOr you can pass the file name as an argument to the function.
This is by far the simplest and most efficient method to capture a snapshot — and the same approach we use at Halodoc.
Here’s our implementation:
import { writeHeapSnapshot } from 'node:v8';
function generateHeapSnapshot(type = 'Baseline') {
const baseDir = process.env.NODE_DIAGNOSTIC_DIR_PATH;
const now = new Date();
const yyyymmdd = now.toISOString().slice(0, 10).replace(/-/g, '');
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
const seconds = now.getSeconds().toString().padStart(2, '0');
const hhmmss = `${hours}${minutes}${seconds}Z`;
const pid = process.pid;
// Format: {type}.Heap.{yyyymmdd}.{hhmmss}.{pid}.heapsnapshot
const filePath = `${baseDir}/${type}.Heap.${yyyymmdd}.${hhmmss}.${pid}.heapsnapshot`;
console.log(`Heap snapshot being written to filePath: ${filePath}`);
try {
const savedPath = writeHeapSnapshot(filePath);
console.log(`Heap snapshot written to ${savedPath}`);
} catch (err) {
console.error('Failed to write heap snapshot:', err);
}
}
We call this function from two places — when the server receives a SIGUSR2 signal and when the application starts:
process.on('SIGUSR2', () => {
generateHeapSnapshot('Manual');
});
function run() {
const port = process.env.PORT || 4000;
// Start up the Node server
const server = app();
// Generate baseline heap snapshot after server is configured but before listening
generateHeapSnapshot();
server.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port} in ${process.env.NODE_ENV} mode`);
});
}Why these two triggers ?
- App Start: The snapshot taken during initialization is our 'Baseline'. It captures the server's memory footprint in a clean state, before processing any traffic. This is the crucial reference point we compare against. Generated filename will be in this format:
Baseline.Heap.{yyyymmdd}.{hhmmss}.{pid}.heapsnapshot SIGUSR2: We listen forSIGUSR2because it's a user-defined signal that is safe to use. It's not typically used by the OS (unlikeSIGINTorSIGTERM), so it provides a perfect, on-demand hook for us to manually trigger a snapshot without stopping the application. Generated filename will be in this format:Manual.Heap.{yyyymmdd}.{hhmmss}.{pid}.heapsnapshot
Automatic Snapshots on OOM Events
We also start the Node.js instance with two additional flags —--heapsnapshot-near-heap-limit and --diagnostic-dir —
to automatically generate snapshots during Out of Memory (OOM) events.
--heapsnapshot-near-heap-limit→ defines how many snapshots Node.js will attempt before shutting down. At Halodoc, we have set this value to 2. This provides a few crucial data points of the memory state right before the crash, without further overwhelming the already-stressed process by trying to write too many large files.--diagnostic-dir→ specifies where the snapshots are stored.
Here’s how we start the Node.js process:
node \
--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE} \
--heapsnapshot-near-heap-limit=${HEAPSNAPSHOT_NEAR_HEAP_LIMIT} \
--diagnostic-dir=${NODE_DIAGNOSTIC_DIR_PATH} \
dist/server/main.js
This method is not 100% guaranteed. An OOM event means the process is in a critically unstable state. It is possible for the process to terminate so abruptly that it doesn't have time to finish writing the snapshot. This is why having an option to manually capture snapshots is essential.
Triggering Snapshots Manually
While baseline and OOM snapshots are a great start, we sometimes need to capture an additional snapshot at a specific moment—for instance, when monitoring shows memory has reached ~50% of its allocation.
To support this on-demand debugging, we use a minimal, private automation service for our developers. This service is configured with least-privilege RBAC (Role-Based Access Control) and has exactly one job: to trigger a snapshot.
Since our Node.js process listens for the custom SIGUSR2 signal, this automation simply sends that signal to the target application container. This automatically triggers our snapshot generation function, all without requiring direct server access.
Snapshot Storage Strategy
Since heap snapshots can range from a few MB to several GBs depending on memory usage, it’s not practical to store them on the server.
Instead, we mount an Amazon S3 bucket as a volume to the Node.js instance. Every time a snapshot is taken, it is written directly to this S3-mounted directory.
Heap snapshots are highly sensitive artifacts because they can contain anything in your application's memory, including PII. This storage location must be treated as highly secure. Our S3 bucket is configured with:
- Server-Side Encryption (SSE-S3) to encrypt all snapshots at rest.
- Block all public access.
- A strict IAM policy that restricts access only to authorized personnels.
- No cross-region replication to ensure data residency.
To control storage costs, we’ve configured a 15-day expiry policy on this S3 directory.
This ensures that old heap snapshots are automatically deleted, keeping our storage clean and cost-efficient.
⚠️ Note: Taking a heap snapshot temporarily pauses the event loop and increases CPU utilization. Always schedule snapshots during low-traffic hours in production.
Step-by-Step: Analyzing Heap Snapshots
Once you’ve captured two or more snapshots — e.g., before and after a suspected leak — here’s the process:
1. Load the Snapshots
Open both in Chrome DevTools under the Memory tab.
2. Use the "Comparison" & "Summary" View
The comparison view highlights what’s growing between snapshots.
You are looking for constructors with a large # Delta (many new objects) or a large Size Delta (a lot of new memory).
Example:
(+) String (Size Delta: +55.7 MB)
(+) Array (Size Delta: +28.9 MB)This Size Delta is based on shallow sizes, so it's only a hint, not the final answer. A large Size Delta is simply a leak candidate.
To confirm the leak, you must find the object that is retaining this memory. Your workflow should be:
- Comparison View: Identify a candidate (e.g.,
(string)shows a largeSize Delta). - Switch to "Summary" View: Select your second snapshot (the larger one).
- Find the Candidate: Use the Class Filter to find the
(string)constructor. - Confirm the Leak: Sort by the Retained Size column.
If you see your candidate (string) at the top of the list with a massive Retained Size, you've likely found a leak. The next step is to click on it and find out what is retaining it.
3. Explore the "Retainers" Tree
Click a leaking object to open the Retainers pane — this shows who is holding a reference to it.
Follow the chain up to the root (often a closure, cache, or global variable).
Example: Closure Leak in SSR
Imagine your Angular SSR server stores each request in a global cache for debugging:
const cache = [];
app.get('*', (req, res) => {
cache.push(req); ❌ never cleared
res.render('index');
});Each request keeps the request object (and all associated data) alive — memory grows forever.
After two snapshots:
- The comparison shows growing
IncomingMessageobjects. - The retainer chain leads to our
cachearray. - Fix: clear or scope it properly.
Common Memory Leak Patterns
| Pattern | Symptom | Fix |
|---|---|---|
| Lingering timers or intervals | setInterval or setTimeout callbacks continue executing after a request or component teardown. Heap snapshots often show growing Timer objects. | Always call clearInterval() or clearTimeout() during teardown. In SSR, clean up timers in lifecycle hooks like ngOnDestroy(). |
| Unremoved event listeners | Event listeners remain attached after DOM elements or components are destroyed, often causing MaxListenersExceededWarning. | Use removeEventListener() (for DOM) or removeListener() (for Node) when no longer needed. Prefer { once: true } for one-time listeners. |
| Unclosed RxJS subscriptions | Heap snapshots show growing Subscriber or Subject objects that persist across requests or renders. | Use RxJS operators like takeUntilDestroyed(), takeUntil(), first(), or finalize() to ensure subscriptions complete properly. |
| Improper dependency injection | Root-level services hold large data structures that grow over time or across requests. | Scope services correctly — inject heavy or per-request state only in components or feature modules, not at the application root. |
| Closures retaining large objects | Closures accidentally capture entire req, res, or payload objects even after request completion, preventing garbage collection. | Copy only necessary fields and nullify references after use. Avoid storing large objects inside long-lived closures or callbacks. |
| Detached DOM or component references | DOM nodes or SSR-rendered components remain referenced after re-rendering or removal. | Remove DOM references and event bindings during component destruction. Avoid storing DOM elements in global or service-level variables. |
Best Practices
- Capture multiple snapshots — at steady intervals — to identify growth patterns.
- Always analyze differences, not a single snapshot.
- Avoid analyzing during GC events — memory temporarily fluctuates.
Summary: Your Memory Debugging Checklist
- Identify rising memory in monitoring (e.g., New Relic, Prometheus, Grafana) or by setting up alert system which sends an alert when memory utilization crosses certain threshold.
- Capture at least 2-3 heap snapshots at different times.
- Compare snapshots → locate growing objects.
- Trace retainers → identify reference chain.
- Fix root cause → deploy → verify with fresh snapshots.
Conclusion
Debugging memory leaks isn’t glamorous, but it’s one of the most valuable skills for engineers working with SSR, microservices, or high-traffic APIs. Left unchecked, these slow-growing leaks are the direct cause of server going down, degraded performance, and ultimately, a poor experience for users.
Heap snapshots give you the superpowers to see what’s inside the memory black box.
By building this systematic process for capturing and analyzing snapshots at Halodoc, our engineering teams are no longer guessing. We have a clear, data-driven path to identify and fix memory leaks. This approach is a critical part of our application performance and reliability strategy, ensuring our services remain stable and performant as we continue to scale for millions of users.
🔗 References
Join us
Scalability, reliability and maintainability are the three pillars that govern what we build at Halodoc Tech. We are actively looking for engineers at all levels and if solving hard problems with challenging requirements is your forte, please reach out to us with your resume at careers.india@halodoc.com.
About Halodoc
Halodoc is the number 1 Healthcare application in Indonesia. Our mission is to simplify and bring quality healthcare across Indonesia, from Sabang to Merauke. We connect 20,000+ doctors with patients in need through our Tele-consultation service. We partner with 3500+ pharmacies in 100+ cities to bring medicine to your doorstep. We've also partnered with Indonesia's largest lab provider to provide lab home services, and to top it off we have recently launched a premium appointment service that partners with 500+ hospitals that allow patients to book a doctor appointment inside our application. We are extremely fortunate to be trusted by our investors, such as the Bill & Melinda Gates Foundation, Singtel, UOB Ventures, Allianz, GoJek, Astra, Temasek, and many more. We recently closed our Series D round and in total have raised around USD$100+ million for our mission. Our team works tirelessly to make sure that we create the best healthcare solution personalized for all of our patient's needs, and are continuously on a path to simplify healthcare for Indonesia.