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.
An unhandled rejection storm is one of those production issues that does not show up as high CPU or a full heap. Promises fail, nobody catches the error, and the process keeps running. On newer Node.js versions, that same pattern can take the process down. Until then, you only see a growing list of failures and a log that is hard to read after the fact.
In this blog, we’ll walk through how Unhandled Rejections show up in a Node.js application, what typically causes them, and how to trace them 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 an ‘Unhandled Rejection’ in Node.js?
A Promise is a stand-in for work that finishes later. If that work fails, the Promise rejects. Someone has to handle the failure `a .catch()` on the chain, or `try/catch` around `await`. When neither is there, Node.js fires `unhandledRejection`.
One missed catch is a bug. A storm is hundreds of those in a short window, usually a timer or a request handler that fires `Promise.reject()` (or an `await` that throws) in a loop.
What Causes ‘Unhandled Rejections’ in Node.js?
Here is the usual list of causes for the ‘Unhandled Rejection Storm’ problem.
- Missing .catch(): A promise chain ends `at .then()` and never handles the failure path.
- await without try/catch: An async function awaits a call that rejects and has no `try/catch` around it.
- Fire-and-forget work: Starting a promise from a timer or request and not keeping a handle to `attach .catch()`.
- Swallowed at the process: A `process.on(‘unhandledRejection’)` listener logs the error, but the original call site still has no handler.
- Retry loops that reject: A poller or batch job that rejects on every tick and never attaches a handler.
How to Simulate Unhandled Rejection Storm in Node.js
To understand how Unhandled Rejections appear in the diagnostic data, let’s reproduce the issue using a sample Node.js application.
The following program starts a timer that creates 100 rejected promises every second. None of them has `a .catch()`. The error message is Storm rejection #N so you can match the table back to this file.
``` JS Code (rejection-storm.js)class RejectionStormDemo { constructor() { this.isRunning = false; this.intervalId = null; this.rejectionCount = 0; } start() { if (this.isRunning) return; this.isRunning = true; console.log('Starting rejection storm simulation...'); // 100 rejected promises every second. No .catch() on any of them. this.intervalId = setInterval(() => { for (let i = 0; i < 100; i++) { Promise.reject(new Error(`Storm rejection #${this.rejectionCount + i}`)); } this.rejectionCount += 100; }, 1000); } stop() { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } this.isRunning = false; this.rejectionCount = 0; }}module.exports = new RejectionStormDemo();```
How to Capture Diagnostic Data for Troubleshooting Unhandled Rejection Storm
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 setup yCrash for your Node.js application, follow the below steps:
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 Unhandled Rejections Using the Diagnostic Data
In this section, we’ll see how to analyze an Unhandled Rejection Storm 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 open the Unhandled Rejections table to find the call site.
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 root cause is 500 unhandled promise rejections, coming from Storm rejection # errors in rejection-storm.js.
The overview also mentions an event listener leak and low process memory, but these are secondary findings. App Memory on this capture is about 0% and App CPU is about 2%, so this is not a CPU or heap problem.
Issues in application and device

Fig: RCA Summary highlighting the issues in application and device
As shown in the above screenshot, three application-level issues are reported. The one that matters for this walkthrough is:
- Unhandled promise rejections
Ignore the duplicate ms package warning, the event listener increase of 20 (91 to 111), and the device note that the process is only using about 18% of memory. They do not explain the storm.
Step 3: Open the Unhandled Rejections table
In the Issues in Application section of the RCA Summary page, click the “details here” link at the end of the Unhandled promise rejections problem. This opens the Unhandled Rejections section of the Node.js Internals report.

Fig: Unhandled Rejections table showing Storm rejection rows
The banner repeats the finding: 500 promise rejections had no handler. The table lists them one by one. The first few rows have the same timestamp, 2026-09-08 17:37:10, with reasons such as Storm rejection #0, #1, #2, and so on. Each row is marked Single occurrence because every `Promise.reject()` creates a separate rejection event.
Seeing many rejections like this points to a timer or loop repeatedly creating rejected promises, rather than a single bad request.
Next, open one of the rows and look at its stack.
Step 4: Open View Details and find the call site
Click View Details on the first row, Storm rejection #0.

Fig: Stack trace pointing at rejection-storm.js:38
The stack points directly to the file and line where the rejection happens. The error Storm rejection #0 comes from `Timeout._onTimeout` in `rejection-storm.js:38:32`.
Below it, you can see `listOnTimeout` and `processTimers`, which confirms that the code is running from a timer callback. There is `no .catch()` or `try/catch` handling the rejected promise on this path.
Once you have identified the call site, you don’t need to dig into the CPU Profiler or Heap Space for this issue. The problem is in the timer code.
Fix the timer so every rejected promise is handled, or avoid creating promises that the application does not intend to wait for.
How to Fix Unhandled Rejections in Node.js
The following are the potential solutions for this issue:
- Put `try/catch` around every await that can fail, and attach `.catch()` to every promise you do not await.
- Do not start work from a timer or request handler and walk away. Keep the promise and handle the failure, or do not create it.
- A `process.on(‘unhandledRejection’)` listener is for last-resort logging or shutdown. It is not a substitute for handling the promise at the call site.
- If a batch job can fail 100 times a second, fail closed: stop the loop, surface one error, and fix the caller.
Conclusion
Diagnosing and identifying the root cause of Unhandled Rejections can be challenging when CPU and memory look quiet. In this example, the diagnostic data was captured using the yc-360 script and analyzed using the yCrash server, which helped correlate 500 unhandled rejections with one timer callback.
The analysis traced the issue to `Promise.reject()` inside `Timeout._onTimeout` in rejection-storm.js:38. The RCA Summary displayed this issue and marked it as FATAL, and View Details showed Storm rejection #0 coming from the same line.
The fix is to handle rejected promises at the call site, or avoid creating fire-and-forget promises that the application does not need. This addresses the actual problem and prevents the same issue from happening again.
