Production issues in Node.js applications can be challenging to diagnose, especially when different problems exhibit similar symptoms. To make troubleshooting easier, we’re introducing a Node.js Production Troubleshooting Series, where each article focuses on one specific production issue, explores its underlying causes, and demonstrates a practical, step-by-step approach to identifying the root cause.
A promise leak is one of those production issues that does not show up as a hung event loop or a full rejection table. The problem happens when your code creates Promises but never settles them. They are neither resolved nor rejected. As long as those Promises are still pending, the data referenced by their executor functions can remain in the V8 heap.
Because of this, the host’s overall RAM usage may look normal at first. However, V8 heap usage keeps growing, and the gap between the heap currently in use and the V8 heap limit gets smaller. Over time, this can lead to increasing memory usage and eventually an out-of-memory problem.
In this blog, we’ll walk through how a Promise Leak shows up in a Node.js application, what typically causes it, and how to trace it back to the exact root cause. We’ll also simulate the issue in a sample application, capture diagnostic data as it happens, and analyze it step by step to pinpoint what’s going wrong.
What is a ‘Promise Leak’ in Node.js?
A Promise normally gets cleaned up after it is resolved or rejected. The problem starts when a Promise stays pending and is never settled.
For example, if the Promise’s executor keeps references to strings, objects, or other data, those references can remain in memory as long as the Promise is still pending. V8 cannot garbage-collect those objects while they are still reachable.
As more pending Promises accumulate, heap usage keeps growing and can eventually approach the V8 heap limit.
What Causes a ‘Promise Leak’ in Node.js?
Here is the usual list of causes for the ‘Promise Leak’ problem.
- Promises that never settle: Code creates a Promise with `new Promise((resolve, reject) => { … })` but never calls `resolve()` or `reject()`. If the Promise is holding references to other data, that data can remain in memory.
- Forgotten resolve in a map: The application stores the `resolve` function in a map while waiting for a job to finish, but the job never completes and the entry is never cleaned up.
- Fire-and-forget work that retains payloads: The application starts asynchronous work without waiting for it to finish, while the pending Promise continues to hold a large string, object, or other payload.
- Caches of pending Promises: An application may cache Promises for requests or jobs. If those Promises never settle and the cache never removes them, they can keep accumulating.
How to Simulate Promise Leak in Node.js
To understand how a Promise Leak appears in the diagnostic data, let’s reproduce the issue using a sample Node.js application.
The following program creates batches of Promises whose executors keep an 8 KB string and the unused resolve/reject callbacks. Nothing ever settles. It stops allocating around 82% of the V8 heap limit so the process stays up for capture.
``` JS Code (promise-leak.js)const v8 = require('v8');class PromiseLeakDemo { constructor() { this.promises = []; this.retained = []; this.intervalId = null; } start() { if (this.intervalId) return; console.log('Starting promise leak...'); this.intervalId = setInterval(() => { const used = process.memoryUsage().heapUsed; const limit = v8.getHeapStatistics().heap_size_limit; if (used >= limit * 0.82) { console.log('holding unresolved promises — capture now'); return; } for (let i = 0; i < 400; i++) { const payload = 'x'.repeat(8 * 1024); const promise = new Promise((resolve, reject) => { this.retained.push({ resolve, reject, payload }); }); this.promises.push(promise); } }, 500); } stop() { clearInterval(this.intervalId); this.promises = []; this.retained = []; }}module.exports = new PromiseLeakDemo();```
How to Capture Diagnostic Data for Troubleshooting Promise Leaks
yCrash is a diagnostic tool that captures runtime performance data from a live Node.js process, including CPU usage, event loop lag, worker thread activity, and call stacks, and turns that data into a report that points to the root cause. To set up yCrash for your Node.js application, follow the steps below:
1. Install yCrash
yCrash is available in both Cloud and On-premises versions. Use one of the options below to get started:
- Cloud Service: Create an account to use the cloud version.
- On-premises: Register for a 14-day trial and install the yCrash application on your local machine or within the organization’s environment.
2. Set up the yCrash Node.js hook first
Please note that this is a one-time setup. The hook must be loaded into your Node.js application before yCrash can capture the Node.js internals data that matters for diagnosis, things like worker thread CPU, event loop lag, method profiling, and call stacks. To set this up, follow the Hook Mode steps here: Node.js Diagnostic Capture. This hook setup adds very minimalistic, almost zero overhead to your application.
3. Launch yc-360 script
Configure and start the yc-360 script to monitor your Node.js process. The full setup and configuration options specific to your deployment is available here: Micro-metrics Monitoring (M3) Mode
When the yc-360 script detects a problem, it captures 360° diagnostic artifacts (GC log, Event loop lags, Worker threads, CPU Profile, Unhandled rejections, Call stack, Active handles, Process, Storage, Kernel, Network, etc.). It uploads them to the yCrash server automatically. yCrash server analyzes the artifacts using advanced pattern recognition and ML algorithms to identify the root cause of the problem.
How to Analyze Promise Leak Using the Diagnostic Data
In this section, we’ll see how to analyze a Promise Leak using the diagnostic data captured by yCrash. We’ll start with the incident on the calendar dashboard, check the RCA Summary to understand what’s going on, and then read Process Overview and Heap Space to see where the memory sits.
Step 1: Open the incident from the yCrash dashboard
When the yc-360 script detects any performance degradation in the Node.js application, it automatically captures 360° diagnostic data and transmits it to the yCrash server. The yCrash server analyses this data and creates an incident in the yCrash dashboard as shown in the screenshot below. Open that incident from the yCrash calendar dashboard to view the reports.

Fig: yCrash dashboard, incident list view
Step 2: Start with the RCA Summary Page
Before diving into details, let’s start with the RCA Summary. yCrash analyzes the Node.js diagnostic data and brings the key findings together in simple language with their severity levels.
In the RCA Summary, we will focus on two sections:
- AI Overview
- Issues in the application and device
AI Overview
At the top of the RCA summary report page, you’ll see an AI Overview section. It gives you an AI-generated summary of the incident, which would make even a non-technical person understand the root cause of the problem. Within the AI Overview section, below the AI summary, you can also click on the ‘Dive Deeper in AI Mode‘ button to open a chat window to ask any question to the AI about this incident.

Fig: RCA Summary highlighting the AI Overview
As shown in the AI Overview above, the write-up leads with an event listener leak and then mentions heap at 79.7% of 560 MB. For this incident, flip that order. Listeners went up by 35. That does not fill 444 MB of heap.
Issues in application and device

Fig: RCA Summary highlighting the issues in application and device
As shown in the above screenshot, 3 application-level issues and 2 device-level issues are reported. The one that matters for a Promise Leak is:
- Elevated heap usage
Skip the rest for this walkthrough. Listeners went from 1,965 to 2,000. The ms package is loaded at 2.0.0 and 2.1.3. Disk usage on C and D is a host note. App Memory on this capture is about 3% and App CPU is about 8%. Handle Growth is basically flat. This is not a listener-leak blog.
Step 3: Read heap used versus the V8 limit
Open the Node.js Internals report from the left navigation and open Process Overview, then the Resource Usage & Limits tab.

Fig: Process Overview showing heap used against the 560 MB limit
`heap.usedMemory` is 444.59 MB. `heap.totalMemory` is 454.33 MB. `heap.memoryLimit` is 560.00 MB. That is the 79.7% finding. The process is not out of host RAM. It is pressing the V8 ceiling you gave it. For more details on V8 heap statistics and memory-related APIs, see the Node.js V8 API documentation.
Step 4: Confirm the objects live in old_space
Open Heap Space from the left navigation.

Fig: Heap Space showing old_space holding the retained objects
`old_space` is using 425.79 MB of 433.18 MB allocated. That is almost all of the used heap. The pie chart is nearly all “used.” These are JavaScript objects, the unresolved Promise closures, and their strings.
How to Fix a Promise Leak in Node.js
The following are the potential solutions for this issue:
- Every Promise you create must settle. Call `resolve` or `reject`, or do not create the Promise.
- Do not stash resolve in a map unless you also remove it when the job finishes or the caller goes away.
- Keep payloads off the executor closure if you can pass them through the resolved value instead.
Conclusion
Diagnosing and identifying the root cause of a Promise Leak can be challenging when the RCA list leads with listeners. In this example, the diagnostic data was captured using the yc-360 script and analyzed using the yCrash server, which helped correlate 79.7% heap usage with `old_space` retention.
The analysis traced the problem to Promises that were never resolved or rejected and continued to hold string payloads in memory. At the time of the capture, `heap.usedMemory` was 444.59 MB out of a 560 MB limit.
The fix is to make sure every Promise has a clear completion path. If a Promise does not need to exist, don’t create it in the first place. This prevents pending Promises from accumulating and holding onto memory.
