Call
Backend Basics

Node.js Event Loop Explained: Timers, Poll, Check, Microtasks, nextTick, and Blocking

Understand the Node.js event loop from synchronous JavaScript through timers, poll, check, Promise microtasks, queueMicrotask, process.nextTick, EventEmitter behavior, libuv worker-pool work, worker threads, and practical event-loop blocking diagnosis.

Published: September 28, 2024
Updated: September 12, 2026
8 min read
Node.js Event Loop Explained: Timers, Poll, Check, Microtasks, nextTick, and Blocking

Node.js Event Loop Explained: Timers, Poll, Check, Microtasks, nextTick, and Blocking

The Node.js event loop is the runtime mechanism that lets one JavaScript thread coordinate many asynchronous operations without waiting synchronously for each network or filesystem task to finish.

The most important idea is not memorizing a phase diagram. It is understanding this rule:

JavaScript callbacks must finish before the event loop can continue processing other callbacks on that thread.

That is why Node can handle many waiting I/O operations efficiently—and why one CPU-heavy callback can delay unrelated requests.

JavaScript execution starts on one call stack

Consider:

console.log('A');
console.log('B');

Those statements execute synchronously on the JavaScript stack.

When code starts asynchronous work, Node delegates parts of that work to the operating system or libuv mechanisms and later schedules a callback when the result is ready.

The callback does not interrupt currently running JavaScript. It waits until the runtime reaches an appropriate opportunity to execute it.

The event loop runs in phases

At a high level, libuv/Node cycles through phases that handle categories of callbacks such as:

timers
pending callbacks
idle/prepare (internal)
poll
check
close callbacks

You do not usually write code that targets every phase directly, but understanding timers, poll, and check explains most practical ordering questions.

Timers run callbacks after a threshold, not at an exact instant

setTimeout(fn, 100) means:

> make this callback eligible after roughly 100 ms

It does not mean Node guarantees execution precisely 100 ms later.

If the event loop is busy running JavaScript or processing other work, the callback executes later.

Official Node timer documentation explicitly says it makes no guarantee about exact timing or ordering at the requested millisecond.

This is why a blocked event loop also creates timer drift.

The poll phase handles I/O readiness

The poll phase is central to Node's I/O behavior.

Network and filesystem-related callbacks become runnable when their underlying operations are ready according to libuv/OS behavior.

While the application is mostly waiting on I/O, Node can spend very little JavaScript CPU and still manage many open operations.

This is the workload where Node's model is especially effective.

setImmediate() runs in the check phase

setImmediate() schedules a callback for the check phase, which occurs after poll in the event-loop cycle.

Example:

setImmediate(() => {
  console.log('immediate');
});

Inside certain I/O callbacks, setImmediate() often has more predictable ordering relative to timers because of its relationship with poll/check.

Do not use setImmediate() merely because its name sounds “faster.” It means a particular scheduling point, not zero-delay execution.

setTimeout(fn, 0) is still a timer

A zero-delay timer is placed into timer scheduling behavior with the runtime's minimum/normal timer semantics.

It is not equivalent to “run immediately after this line.”

Depending on where it is scheduled and what other work exists, ordering relative to setImmediate() can differ.

If code correctness depends on a fragile race between a zero-delay timeout and an immediate callback, redesign the synchronization rather than relying on incidental ordering.

Promise callbacks use the microtask queue

Promise continuations do not wait for a full future event-loop turn like a normal timer.

Example:

console.log('start');

Promise.resolve().then(() => {
  console.log('promise');
});

console.log('end');

The synchronous stack finishes first, then the Promise microtask runs before Node proceeds to ordinary later phase work.

Output:

start
end
promise

queueMicrotask() uses the microtask queue too

For portable userland deferral, current Node documentation recommends queueMicrotask() for most cases where developers previously reached for process.nextTick().

Example:

queueMicrotask(() => {
  console.log('later in microtask queue');
});

Microtasks are powerful but can also starve ordinary event-loop work if code recursively keeps adding more microtasks.

process.nextTick() has special Node semantics

process.nextTick() uses a separate next-tick queue that is drained after the current operation and before the event loop continues.

Current Node documentation marks process.nextTick() as Legacy for general userland deferral and recommends queueMicrotask() in most cases.

Recursive next-tick scheduling can prevent the event loop from reaching I/O:

nextTick callback
  schedules nextTick
    schedules nextTick
      ...

Use it only when its exact semantics are genuinely required.

Microtasks can starve I/O too

This general pattern is dangerous:

microtask executes
adds another microtask
microtask executes
adds another microtask

Because microtask queues are drained before proceeding with normal event-loop work, unbounded recursive scheduling can delay timers and I/O.

“Asynchronous” does not automatically mean “fair.”

EventEmitter listeners are synchronous by default

This surprises developers who assume every Node event is asynchronous.

When an EventEmitter emits an event, ordinary listeners are invoked synchronously in registration order.

If one listener performs expensive work, the emit() call cannot complete until that listener returns.

For high-traffic systems, keep event listeners bounded just like HTTP handlers.

async/await is built on Promise scheduling

This code:

async function load() {
  const result = await fetchData();
  return result;
}

does not block the JavaScript thread while fetchData() is waiting asynchronously.

When the awaited promise settles, continuation of the async function is scheduled through Promise/microtask semantics.

But async does not make synchronous CPU work non-blocking:

async function bad() {
  expensiveCpuLoop();
}

The loop still blocks the event loop.

Node also has a worker pool behind some APIs

Not every asynchronous operation is handled directly by OS readiness notifications.

libuv uses a worker pool for selected operations such as parts of:

  • filesystem work
  • DNS functions
  • crypto
  • compression

If many expensive worker-pool tasks saturate that pool, latency can rise even when the JavaScript event loop itself is not CPU-blocked.

Event-loop health and worker-pool saturation are different diagnostic dimensions.

worker_threads are separate from libuv's worker pool

Application worker_threads let your code execute JavaScript in parallel.

They are useful for CPU-heavy JS algorithms.

They are not the same thing as the internal libuv worker pool used by certain standard APIs.

Do not increase one expecting it automatically to fix saturation in the other.

Blocking the event loop affects everyone on that process

Imagine a request handler doing 500 ms of synchronous CPU work.

During that time:

  • timers wait
  • Promise continuations wait once current stack/microtask rules reach that point
  • other HTTP callbacks wait
  • WebSocket callbacks wait

That is the central operational risk of Node's model.

CPU-heavy work should move to a worker pool or another service/process where it cannot monopolize the main loop.

Large JSON can become event-loop CPU work

JSON.parse and JSON.stringify are synchronous JavaScript/native operations from the perspective of the event loop.

For normal API payloads they are fine.

For extremely large objects, parsing/serializing can become a measurable latency spike.

Set body/response limits and profile real payload sizes before reaching for exotic serializers.

Synchronous standard-library methods should stay out of hot request paths

Node often offers both async and sync variants:

readFile / readFileSync
pbkdf2 / pbkdf2Sync

Synchronous forms can be convenient in startup scripts or CLI initialization where no concurrent request work exists.

Inside a production request path, they can block the event loop.

Use async versions for normal server I/O.

The event loop does not provide application backpressure automatically

A producer can still generate work faster than a consumer can handle it.

Examples:

  • WebSocket messages
  • streaming transforms
  • database jobs
  • Promise fan-out

Use bounded queues, stream backpressure, concurrency limits, or load shedding.

The event loop is a scheduling mechanism, not an infinite-capacity queue.

How to diagnose event-loop problems

Measure:

  • event-loop delay/lag
  • event-loop utilization where available
  • CPU profiles
  • route p95/p99 latency
  • synchronous hot functions
  • libuv worker-pool-heavy operations

If CPU is high and event-loop delay spikes, look for blocking JavaScript/native synchronous work.

If event-loop delay is healthy but requests wait on the DB, the bottleneck is likely downstream rather than the loop itself.

Mental model for normal API code

A request may behave conceptually like:

HTTP callback begins
validate synchronously
start async DB query
callback yields
other requests execute
DB result becomes ready
Promise continuation runs
start async remote API call
callback yields
remote result becomes ready
continuation builds response

This works well because waiting I/O does not occupy the JavaScript stack.

Event-loop checklist

When writing Node services, remember:

  1. Synchronous JavaScript runs to completion.
  2. Timers are thresholds, not exact deadlines.
  3. Poll is central to I/O callbacks.
  4. setImmediate() runs in the check phase.
  5. Promise/queueMicrotask callbacks use microtask scheduling.
  6. process.nextTick() has special priority and is now legacy for general deferral.
  7. EventEmitter listeners are synchronous when emitted.
  8. async does not make CPU work non-blocking.
  9. Some standard APIs use libuv's worker pool.
  10. worker_threads are for explicit parallel JavaScript CPU work.
  11. Backpressure and concurrency still need application limits.

The Node.js event loop is powerful because it lets one JavaScript thread stay productive while I/O waits elsewhere. It performs badly when developers confuse non-blocking I/O with non-blocking JavaScript. Keep callbacks short, bound work, and measure event-loop delay whenever latency becomes mysterious.

Official references

Tags:Node.jsEvent LoopJavaScriptAsynclibuvBackend Development
AI & AGENT ENGINEERING

BUILDING AN AI PRODUCT?

Talk to Lofingo about custom autonomous AI agents, enterprise workflow automation, and scalable software systems.

EXPLORE AI SERVICES →