How process.nextTick() Silently Starves the Node.js Event Loop
Recursive process.nextTick() calls never let libuv reach the poll phase, so the Node.js event loop starves. How to spot it and fix it with setImmediate().
This article is published in English.
Sources: Why `process.nextTick()` Can Starve Your Node.js Event Loop
Most backend engineers understand that Node.js runs on a single-threaded event loop powered by libuv, and that non-blocking asynchronous patterns are what keep an API fast and responsive under load.
Yet a subtle hazard is baked directly into the standard library: the split between what are commonly called macrotasks and microtasks.
In services that handle tens of thousands of requests per second, treating process.nextTick() as a harmless shortcut for "run this right after the current function finishes" can bring your entire application's throughput to a halt.
This failure mode is known as I/O starvation. To understand why it happens, you need to look closely at how V8 hands off execution to libuv, and vice versa.
1. Deconstructing the Node.js Event Loop Phases
When a Node.js process starts, the runtime spins up the libuv event loop. This loop is responsible for coordinating timers, network sockets, file descriptors, and child processes, and it does so by cycling through six distinct phases:
┌───────────────────────────┐
┌─>│ timers │ setTimeout(), setInterval()
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ System-level I/O callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ Internal node operations
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ Retrieve new I/O events, execute I/O callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ setImmediate() callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ socket.on('close', ...)
└───────────────────────────┘
The loop advances through these phases in strict order. During the poll phase specifically, the runtime blocks while waiting on incoming connections, checks the OS kernel for data ready on open sockets, and fires the JavaScript callbacks tied to that activity.
2. Where Does process.nextTick() Actually Live?
Here's the detail that catches most developers off guard: process.nextTick() isn't part of the libuv event loop at all.
It's actually a separate mechanism, handled internally by Node.js through its own dedicated microtask queue. Once the V8 call stack becomes empty, the engine is obligated to fully drain any pending microtasks before it returns control to libuv.
What makes this even trickier is the priority ordering that exists within the microtask layer itself:
[ Active V8 Call Stack Finishes ]
│
▼
┌──────────────────────────────────────┐
│ Microtask Queue 1 │
│ process.nextTick Queue │ ◄── Emptied COMPLETELY first
└──────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Microtask Queue 2 │
│ Promise / async-await Queue │ ◄── Emptied second
└──────────────┬───────────────────────┘
│
▼
[ Advance to Next libuv Event Loop Phase ]
Every callback registered through process.nextTick() runs before V8 resolves any standard Promises, and well before libuv gets a chance to move on to timers, check callbacks, or poll for incoming network traffic.
3. The Anatomy of Event Loop Starvation
Since Node.js insists on emptying the entire process.nextTick queue before letting the loop proceed, continually pushing new callbacks onto that queue effectively freezes the event loop in one spot.
Take this batch-processing example as an illustration:
import http from "node:http";
// Mock worker performing continuous state reconciliation
function scheduleWork() {
// Heavy in-memory computation
performSanitization();
// Recursively scheduling next iteration
process.nextTick(scheduleWork);
}
// Kick off the background worker
scheduleWork();
// Spin up a standard HTTP API
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "healthy" }));
});
server.listen(3000, () => {
console.log("Server listening on port 3000");
});
What Happens Behind the Scenes:
scheduleWork()runs its synchronous logic.- It enqueues another reference to itself onto the
process.nextTickqueue. - The V8 call stack empties, and Node.js checks the microtask queue.
- It discovers the freshly queued
scheduleWorkcall and runs it right away. - This cycle repeats without end.
- Result: the runtime never makes it to the
libuvpoll phase. Even though the OS kernel has already bound port3000and is receiving TCP SYN packets, Node.js never actually checks the socket. Incoming HTTP requests pile up in the kernel's backlog until the connecting clients time out with a504 Gateway Timeout.
4. process.nextTick() vs. setImmediate(): The Practical Distinction
It's tempting to treat process.nextTick() and setImmediate() as roughly the same tool, but they sit at opposite ends of the runtime's execution order:
Feature | process.nextTick() | setImmediate()
Where it queues — V8's microtask queue vs. the libuv Check phase (a macrotask)
When it fires — right after the current call stack is empty vs. on the following pass of the event loop
Plays well with I/O? — No, recursive chaining can starve I/O entirely vs. Yes, it hands control back to the loop
Typical usage — normalizing API behavior and internal setup vs. breaking up expensive work into batches
5. Fixing It: Cooperative Multitasking via setImmediate()
For asynchronous workloads that take a while to finish, you want a pattern that never locks up the thread or ignores incoming connections. That pattern is commonly called cooperative multitasking.
Instead of looping through everything at once, you schedule each remaining chunk of work with setImmediate(). This explicitly tells Node.js to hand control back to the event loop after each chunk, giving the poll phase a chance to read waiting network packets, receive responses from database drivers, and service other pending requests before continuing.
/**
* Processes an array in chunks while yielding the thread
* back to libuv to prevent event loop starvation.
*/
function processLargeDataset(records, chunkSize = 100) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, records.length);
// Process current slice
for (; index < end; index++) {
transformRecord(records[index]);
}
if (index < records.length) {
// Yield control: libuv can now poll I/O, timers, and accept HTTP traffic
setImmediate(processChunk);
} else {
console.log("Batch processing completed safely.");
}
}
// Initial trigger
setImmediate(processChunk);
}
6. Legitimate Use Cases for process.nextTick()
None of this means process.nextTick() is a flawed API — it's simply a low-level primitive that needs to be used carefully. In fact, Node's internals rely on it for two important jobs.
A. Avoiding the "Zalgo" Problem
If you're writing a function that takes a callback, that callback needs to fire either always synchronously or always asynchronously — never a mix depending on runtime conditions. Inconsistent timing like this produces subtle, hard-to-reproduce race conditions in calling code.
import { readFile } from "node:fs";
const cache = new Map();
function getCachedConfig(filePath, callback) {
if (cache.has(filePath)) {
// Bad: callback(null, cache.get(filePath)); -> Sync execution!
// Good: Ensures the caller's following lines run first, maintaining async contracts
process.nextTick(() => {
callback(null, cache.get(filePath));
});
return;
}
readFile(filePath, "utf-8", (err, data) => {
if (err) return callback(err);
cache.set(filePath, data);
callback(null, data);
});
}
B. Emitting Events Safely from a Constructor
If a class that extends EventEmitter fires an event from inside its own constructor, there's a problem: the code instantiating that object hasn't had a chance yet to register any listeners, so the event would simply be lost.
import { EventEmitter } from "node:events";
class DatabaseConnection extends EventEmitter {
constructor(connectionString) {
super();
this.connect(connectionString);
// Defer the event until the consumer finishes binding .on('connected', ...)
process.nextTick(() => {
this.emit("connected", { ready: true });
});
}
connect(str) {
// Socket setup logic...
}
}
// Consumer code:
const db = new DatabaseConnection("postgres://localhost:5432");
db.on("connected", (status) => {
// Listener is bound BEFORE the nextTick callback fires!
console.log("Database initialized safely:", status);
});
Guidelines for Production Systems
- Don't let a microtask reschedule itself. A function that keeps calling
process.nextTick()on itself will never let the loop move forward. - Reach for
setImmediate()when splitting up work. If iterating over a collection takes more than a couple of milliseconds, break it into pieces and yield between them withsetImmediate(). - Reserve
process.nextTick()for consistency work. Keep its use narrow — things like emitting events safely during construction, or making sure a cached-value callback resolves with the same timing as an uncached one. - Watch your event loop lag continuously. Whatever APM you use — Datadog, New Relic, OpenTelemetry — track the
nodejs.eventloop.delaymetric. A sustained increase is a strong early signal that something is blocking synchronously or that a microtask loop is starving the runtime.