Site icon yCrash

How to Troubleshoot process.nextTick() Starvation in Node.js

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.

NextTick Starvation is one of those production issues that looks like the process hung, even though there is no obvious `while (true)` loop.

The code calls process.nextTick() to finish some work before moving on to I/O. But that callback schedules another nextTick(), which schedules another one, and so on. The queue never gets a chance to empty.

Meanwhile, timers stop running, HTTP responses get delayed, and the application can feel completely stuck. What makes this harder to spot is that CPU usage may not look particularly high, so your APM dashboard might not raise any alarms.

The result? The application is still running, but users experience freezes and timeouts.

In this blog, we’ll walk through how process.nextTick() starvation 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 ‘NextTick Starvation’ in Node.js?

NextTick Starvation happens when `process.nextTick()` callbacks keep scheduling more `nextTick` callbacks, so the `nextTick` queue never becomes empty. Node drains this queue completely before it moves on, so the event loop never gets back to timers, sockets, or incoming requests. The process stays busy but stops responding to anything else. It looks like an event loop block, but the cause is a `nextTick` chain that never ends.

Even a short delay can cause noticeable jitter. If the event loop stays blocked for a second or more, health checks and load balancers may start treating the process as unresponsive.

yCrash does not specifically label the problem as “nextTick starvation”. Instead, you will typically see Event Loop Lag along with a CPU profile showing where the time is being spent.

What Causes ‘NextTick Starvation’ in Node.js?

Here is the usual list of causes for the ‘NextTick Starvation’ problem.

  1. Recursive process.nextTick(): A callback schedules another `nextTick`, which schedules another, with no bound and no `setTimeout` yield. The queue never drains.
  2. “Finish this before I/O” helpers: Middleware, serializers, or logging code that keeps deferring work onto the `nextTick` queue because it feels safer than doing it now.
  3. Unbounded batches: Walking a large list by nextTick-ing one item at a time so I/O never gets a turn between items.
  4. Anonymous callbacks: Arrow functions on `nextTick` show up as (anonymous) in Method Profiling. You can see the CPU, but not the call site.
  5. Mistaking nextTick for setImmediate: Using `nextTick` as a general “defer this” API. setImmediate` and timers run in a later phase and do not starve the tick queue the same way.

How to Simulate a NextTick Starvation in Node.js

To understand how NextTick Starvation appears in the diagnostic data, let’s reproduce the issue using a sample Node.js application.

The following program keeps calling a named function, nextTickFloodStep, until about 1200 ms have passed. Then it yields with setTimeout so the diagnostic hook can still talk to the process. The name matters. An arrow callback would show up as (anonymous), and you would be guessing.

``` JS Code (nexttick-starvation.js)
function enqueueNextTickFloodStep(state) {
process.nextTick(function nextTickFloodStep() {
if (!state.running) return;
for (let i = 0; i < 200; i++) {
state.n++;
}
if (Date.now() - state.startedAt < state.stallMs) {
enqueueNextTickFloodStep(state);
return;
}
setTimeout(function nextTickFloodYield() {
state.startedAt = Date.now();
enqueueNextTickFloodStep(state);
}, state.yieldMs);
});
}
class NextTickStarvationDemo {
constructor() {
this.isRunning = false;
this.state = null;
}
start() {
if (this.isRunning) return;
this.isRunning = true;
this.state = {
running: true,
n: 0,
startedAt: Date.now(),
stallMs: 1200,
yieldMs: 400
};
console.log('Starting process.nextTick() starvation...');
enqueueNextTickFloodStep(this.state);
}
stop() {
if (this.state) this.state.running = false;
this.isRunning = false;
}
}
module.exports = new NextTickStarvationDemo();
```

How to Capture Diagnostic Data for Troubleshooting NextTick Starvation

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:

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 are 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 NextTick Starvation Using the Diagnostic Data

In this section, we’ll see how to analyze NextTick Starvation 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 drill into Event Loop Lag and the CPU profile to find the nextTick drain.

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

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 event loop stalled for over a second (1210 ms at 04:39:46), and the hot method is `nextTickFloodStep` at 63.03% of CPU.

The overview also mentions an event listener leak. That is a side note. Listeners going up do not drain the nextTick queue.

Issues in application and device

Fig: RCA Summary highlighting the issues in application and device

As shown in the above screenshot, 4 application-level issues and 1 device-level issue are reported. The 2 that matter most for NextTick Starvation are:

Skip the rest for this walkthrough. The ms package is loaded at 2.0.0 and 2.1.3. Listeners went from 88 to 108. The device note says the process is using 19.41% of memory. None of that starved the tick queue. App Memory on this capture is about 1% and App CPU is about 22%, so this is not a heap problem, and it is not a 100% host-CPU problem either.

Step 3: Confirm Fatal Event Loop Lag

In the Issues in Application section, click the “details here” link at the end of the Event Loop Lag problem. This opens the Event Loop Lag section of the Node.js Internals report

Event Loop Lag tells how late the event loop ran by comparing when a timer was supposed to run with when it actually ran. High lag means the main thread was busy and could not process other events.

Fig: Event Loop Lag in Node.js Internals report

As you can see in the above graph, the first sample is a 204 ms Warning at 04:39:18. After that, the event loop stays in the Fatal Spike range, with values around 1205-1207 ms.

The banner also shows a 1210 ms spike at 04:39:46. This matches the roughly 1200 ms drain we are looking for. During each of these spikes, timers and I/O were blocked for a little over a second.

Event Loop Lag tells us that the event loop was blocked, but it does not tell us what was blocking it. To confirm that process.nextTick() was responsible, let’s look at the CPU Profiling report next.

Step 4: Use CPU Profiling to close the investigation

In the CPU Profiler report page, yCrash aggregates the V8 sampling profile into a Function Explorer table (hot methods ranked by self CPU %). It provides an option to inspect the hot methods to do a deep diagnosis.

Open the CPU Profiler report page from the left navigation and scroll to the Function Explorer section.

Fig: Function Explorer in CPU Profiler report

The Function Explorer confirms what we saw on the RCA Summary page: nextTickFloodStep is responsible for most of the CPU usage. It accounts for 63.03% self CPU across 17,401 calls, and 64.57% total CPU on one thread.

A few rows below, processTicksAndRejections shows only 8.35% self CPU, but 73.47% total CPU. This is the Node.js code that keeps processing the nextTick queue.

You can also see (idle) at 23.90%, which represents the time spent in the setTimeout yield between batches. enqueueNextTickFloodStep and nextTick also appear lower in the table.

For a closer look, click Inspect on the `nextTickFloodStep` row.

Fig: Inspector view of nextTickFloodStep

The Inspect view shows the same numbers: 63.03% self CPU, 64.57% total CPU, and 17,401 calls.

Expand the parent frames, and you can trace the call back to node:internal/process/task_queues and processTicksAndRejections.

This is what confirms that the event loop is spending its time draining the nextTick queue.

How to Fix NextTick Starvation in Node.js

The following are the potential solutions for this issue:

  1. Bound how much work you put on `process.nextTick`. Do not chain nextTick from inside nextTick with no stop condition.
  2. Move heavy or unbounded follow-up work to `setImmediate`, `setTimeout`, or a worker. nextTick is for a small, must-finish-this-turn job.
  3. Use a named function for the callback so Method Profiling can show it. Anonymous arrows hide the cause as (anonymous).
  4. Watch Event Loop Lag together with the CPU profile. Lag alone is not enough. The hot method and the processTicksAndRejections path are what name nextTick.

Conclusion

Diagnosing and identifying the root cause of NextTick Starvation can be challenging when the host CPU looks only moderately busy. In this example, the diagnostic data was captured using the yc-360 script and analyzed using the yCrash server, which helped correlate the Fatal 1210 ms stall with nextTickFloodStep.

The analysis traced the issue to a recursive process.nextTick drain. Event Loop Lag sat around 1205–1210 ms, and Inspect showed nextTickFloodStep under processTicksAndRejections.

The fix is to limit the amount of work scheduled with process.nextTick(), or move some of the work to a later phase of the event loop. This gives the event loop a chance to process timers, I/O, and incoming requests normally.

Exit mobile version