FullStack Course LogoFullStack Course
Module: Nodejs
Nodejs·119·13 MIN READ

119: Node Async Runtime — Event Loop, Timers, Microtasks, `nextTick`, and libuv

TOPICS COVERED: Node Async Runtime — Event Loop, Timers, Microtasks, `nextTick`, and libuv

Learning objectives

You will learn to:

  • explain how Node can handle many concurrent I/O operations while JavaScript runs on one main thread;
  • distinguish the call stack, event loop, task queues, and microtasks;
  • understand the roles of timers, setImmediate, Promise microtasks, and process.nextTick;
  • describe the libuv thread pool at a practical level;
  • identify operations that block the event loop;
  • reason about asynchronous ordering instead of memorizing an isolated set of outputs;
  • avoid starving I/O with nextTick;
  • use Promise and async patterns appropriately;
  • model bounded concurrency instead of starting unlimited work at once.

The main mental model

Start with the path an asynchronous operation takes:

text
JavaScript call stack
        ↓
starts async operation
        ↓
Node/libuv/OS waits or performs work
        ↓
completion becomes eligible
        ↓
event loop schedules JavaScript callback
        ↓
callback runs on JavaScript thread

The event loop coordinates when JavaScript callbacks execute. It does not turn CPU-heavy JavaScript into parallel work. JavaScript still runs on the JavaScript thread; the surrounding runtime arranges for waiting, native work, and completed operations to be handled at the appropriate time.

That distinction is the foundation for the rest of this lesson. A request that is waiting on a socket can coexist with other requests, but a long-running JavaScript loop still prevents their callbacks from running.

Call stack

When JavaScript calls a function, that function gets a frame on the call stack. Calls made from it are placed above it, and the frames leave the stack as the functions return.

js
function c() {
  console.log('c');
}

function b() {
  c();
}

function a() {
  b();
}

a();

Conceptually, while the innermost call is running, the stack looks like this:

text
a
b
c
console.log

The exact implementation is more detailed than this picture, but the useful idea is simple: only the current synchronous chain is executing on this JavaScript thread. When a function returns, its frame leaves the stack.

A synchronous infinite loop never gives control back to the runtime. As a result, the event loop cannot reach other callbacks, even if those callbacks are already ready to run.

Basic ordering

Consider this small example:

js
console.log('A');

setTimeout(() => {
  console.log('timer');
}, 0);

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

console.log('B');

The broad ordering is:

text
A
B
promise
timer

A and B are synchronous, so they run before the current call stack finishes. The Promise reaction is a microtask, and it runs before the runtime returns to later event-loop work. A zero-millisecond timer is eligible as soon as the timer rules allow it; zero does not mean “run immediately.”

This is a useful first model, not a universal scheduling formula. Node has several event-loop phases and gives process.nextTick special treatment. The surrounding context can change the relative order of timers, I/O callbacks, and immediates, so do not infer every Node scheduling rule from this one snippet.

Event-loop phases

A practical simplified model includes phases such as:

text
timers
pending callbacks
poll
check
close callbacks

The real implementation has additional details, and the exact behavior can vary with Node and libuv versions. For everyday reasoning, these relationships are the important ones:

  • timer callbacks are handled in the timers phase;
  • I/O completion is associated with poll-related processing;
  • setImmediate() callbacks run in the check phase;
  • after JavaScript work reaches a scheduling boundary, microtasks and Node's next-tick work can affect what runs next.

setImmediate() is therefore not simply a synonym for setTimeout(..., 0). Their relationship depends on where they were scheduled and on the current event-loop context.

Learn the documented behavior for the Node version you support. When an edge case matters, run a focused experiment on that version rather than relying on an output remembered from a different release.

setTimeout

js
setTimeout(() => {
  console.log('later');
}, 100);

The 100 value is a minimum-ish scheduling threshold, not a guarantee that the callback begins exactly 100 milliseconds later. The callback still has to wait for the JavaScript thread to become available and for the event loop to process it.

For example:

js
const start = Date.now();

setTimeout(() => {
  console.log(Date.now() - start);
}, 10);

while (Date.now() - start < 1000) {
  // block
}

The loop occupies the JavaScript thread for about one second. The timer becomes eligible while that loop is running, but its callback cannot run during the loop. The measured value will therefore be much larger than 10 milliseconds.

This is the practical difference between a timer delay and a deadline: a timer says when work may be considered, not when the callback is guaranteed to execute.

setInterval

js
const id = setInterval(() => {
  console.log('tick');
}, 1000);

setTimeout(() => {
  clearInterval(id);
}, 5000);

Intervals can drift when callbacks take a long time or the event loop is delayed. They also exist only in the memory of the current process. If the process crashes or is restarted, the interval disappears.

For work that requires precise scheduling or business guarantees, use a scheduler or durable queue. Treating setInterval as a reliable job scheduler can lose work during a restart and can produce timing behavior that is very different from the intended schedule.

setImmediate

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

setImmediate schedules work for a later event-loop turn, specifically the check phase. It can be useful when you need that scheduling relationship, particularly in code that is already handling I/O.

Do not replace ordinary Promise-based asynchronous code with setImmediate just to make it appear more asynchronous. Choose it when the phase or turn semantics solve a real problem; otherwise, use the Promise or async API that expresses the operation itself.

Promise microtasks

js
queueMicrotask(() => {
  console.log('microtask');
});

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

Both callbacks are microtask work. Microtasks run after the current JavaScript execution completes and before the runtime moves on to later event-loop work. Promise reactions use this mechanism, and queueMicrotask lets you schedule a microtask directly.

Microtasks have high priority within this boundary, so they can also starve progress. Code that continually schedules another microtask can prevent timers and I/O callbacks from getting a turn. If work is substantial or potentially unbounded, yield through normal event-loop scheduling instead of extending one uninterrupted microtask chain.

process.nextTick

Node has a special next-tick queue:

js
process.nextTick(() => {
  console.log('next tick');
});

Node processes this queue before the normal event loop continues and before ordinary microtask progression in Node's documented ordering. That gives nextTick very high priority, but it also makes misuse particularly visible.

Recursive next-tick scheduling can starve I/O:

js
function loop() {
  process.nextTick(loop);
}

loop();

The callback keeps adding more next-tick work before the event loop can make progress. The result is a process that can remain busy while timers and I/O callbacks wait indefinitely.

Do not use nextTick as a generic asynchronous primitive. Prefer Promise APIs, queueMicrotask, or ordinary event-loop scheduling based on the behavior you need.

Why nextTick exists

nextTick is useful in a few specific patterns. Historically and in current core/library code, it can be used to:

  • defer a callback until after the current call stack has unwound;
  • give callers a chance to finish setup or attach listeners before an error or event is delivered;
  • maintain asynchronous API consistency in selected core and library patterns.

Application code rarely needs heavy use of it. If the only goal is “run this later,” first ask whether a Promise, queueMicrotask, timer, or immediate communicates the intended scheduling semantics more clearly.

libuv thread pool

Some Node operations use libuv's worker pool instead of relying entirely on asynchronous operating-system APIs. Common examples include selected:

  • filesystem operations;
  • DNS functions;
  • cryptographic operations;
  • compression.

The exact operation matters, so consult the API documentation rather than assuming every function in a category uses the pool.

The worker pool does not mean that JavaScript callback code runs on those pool threads. An expensive native operation may execute there; once it completes, its completion is returned to the event-loop scheduling path, and the JavaScript callback runs on the JavaScript thread.

Thread-pool saturation

The pool is limited. If an application starts many expensive pool-backed operations, they compete for those workers and wait in line.

For example, this combination can increase latency:

text
many password hashes
+
large filesystem work
+
compression

Increasing UV_THREADPOOL_SIZE is not a universal fix. More workers can change resource usage and contention, and it does not make CPU-heavy JavaScript parallel. Measure the workload first, then consider whether the right solution is:

  • worker threads;
  • a dedicated service;
  • a different asynchronous architecture;
  • a queue;
  • an explicit capacity limit.

The useful diagnostic question is not merely “is the thread pool busy?” It is “which work is competing for capacity, and what latency or resource limit should control it?”

Async functions

js
async function loadTask(id) {
  const response = await fetch(`https://api.example/tasks/${id}`);

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  return response.json();
}

await suspends this async function until the awaited Promise settles. It does not pause the entire Node process. While the network operation is pending, other callbacks and other requests can continue to run.

The function still has an important failure boundary: a non-OK HTTP response is not automatically a rejected fetch Promise, so this example checks response.ok and throws explicitly. That keeps the caller's error handling aligned with the application's meaning of failure.

Sequential versus concurrent awaiting

These awaits run in sequence:

js
const user = await getUser();
const teams = await getTeams();

If getUser() and getTeams() do not depend on each other's results, the second operation does not need to wait for the first. Sequential awaits can create a latency waterfall.

The independent operations can instead be started together:

js
const [user, teams] = await Promise.all([
  getUser(),
  getTeams(),
]);

Use this pattern only when the operations are genuinely independent and the available capacity can handle them concurrently. If getTeams() needs the user ID returned by getUser(), parallelizing them would be incorrect rather than faster.

Promise.all

Promise.all fails fast when one of its inputs rejects. Use it when the overall operation requires every result to be available.

“Fails fast” describes when the combined Promise settles; it does not necessarily cancel the other operations that were already started. If those operations are expensive or have side effects, cancellation and cleanup need to be designed separately.

Promise.allSettled

js
const results = await Promise.allSettled([
  sendEmail(),
  updateAnalytics(),
  notifyWebhook(),
]);

Promise.allSettled waits for every input and reports each outcome. That is useful when the caller needs to distinguish successful and failed operations individually, such as independent notifications.

Do not use allSettled as a way to silently ignore a critical failure. Inspect the returned statuses and apply the business rule explicitly. A collection of settled results is only useful if the application decides what those results mean.

Promise.race

Promise.race settles when the first input settles, whether that input fulfills or rejects. It can be useful for some timeout and race patterns, although modern APIs often accept an AbortSignal directly.

A timeout Promise alone does not cancel the underlying work. It only changes which Promise the caller observes first. Unless the operation supports cancellation and you invoke it, the request, socket, or other work may continue in the background.

Promise.any

Promise.any fulfills with the first successful result. It rejects with an AggregateError only when all inputs reject.

This is useful for redundant providers where the first successful response is sufficient. When possible, cancel the remaining requests after a winner is available; otherwise, the application may still pay for unnecessary work and consume provider or connection capacity.

Cancellation with AbortController

Use an AbortController when the API supports an abort signal:

js
const controller = new AbortController();

const timeout = setTimeout(() => {
  controller.abort(new Error('timeout'));
}, 5000);

try {
  const response = await fetch(url, {
    signal: controller.signal,
  });
} finally {
  clearTimeout(timeout);
}

The timer aborts the fetch after the chosen threshold, and the finally block clears the timer whether the fetch succeeds, fails, or is aborted. The API being called must honor the signal for cancellation to have the intended effect.

Modern Node versions also provide useful AbortSignal helpers. Use the documented helpers supported by your target version instead of inventing home-grown cancellation flags that an underlying API cannot observe.

Unbounded concurrency problem

Starting every operation at once is easy to write and often expensive to run:

js
await Promise.all(
  tenThousandUsers.map((user) => sendEmail(user)),
);

Depending on the operation and its dependencies, this can cause:

  • socket explosion;
  • rate-limit failures;
  • memory pressure;
  • database pool saturation.

The fact that Promise.all is concise does not mean the system has capacity for all of the work. Use bounded concurrency when the input can be large or the downstream service has a finite limit.

Simple worker pattern:

js
async function mapWithConcurrency(items, limit, worker) {
  const results = new Array(items.length);
  let nextIndex = 0;

  async function run() {
    while (true) {
      const index = nextIndex++;
      if (index >= items.length) return;

      results[index] = await worker(items[index], index);
    }
  }

  await Promise.all(
    Array.from({ length: Math.min(limit, items.length) }, run),
  );

  return results;
}

The workers claim the next index and wait for each item to finish before claiming another. The results array preserves input order even though the individual operations can complete in a different order. Math.min also avoids creating more workers than items.

Production packages and queues may provide richer behavior, including retries, cancellation, fairness, rate limits, and failure policies. The core principle remains the same: make capacity an explicit part of the design.

Backpressure across async systems

Bounded concurrency is one form of backpressure. It prevents a producer from handing an unlimited amount of work to a consumer that has finite capacity.

If a producer creates work faster than a consumer, database, or API can handle it, the system needs an explicit policy. Useful choices include:

  • limits;
  • a buffering policy;
  • queues;
  • rejection;
  • throttling.

Without such a policy, memory or downstream resources become the accidental buffer. Streams later provide native backpressure semantics for byte and object flow, but the same capacity question applies to other asynchronous systems.

Blocking examples

An operation can be asynchronous from the caller's perspective and still involve synchronous work somewhere in the request path. These examples are common ways to block the JavaScript thread.

JSON

js
JSON.parse(veryLargeString);

JSON.parse is synchronous CPU work. Parsing a very large payload on a request path prevents other JavaScript callbacks from running until parsing completes.

Regex

Pathological regular expressions can block the event loop. A pattern with problematic backtracking can consume substantial CPU for certain input, so validate patterns and input sizes rather than assuming a regex is cheap because it is short.

Crypto

Some crypto APIs are synchronous:

js
crypto.pbkdf2Sync(...)

Avoid expensive synchronous variants on request paths. Where the API and workload permit it, use the asynchronous form or move CPU-heavy work to an appropriate worker architecture.

Filesystem

js
readFileSync(...)

This blocks the JavaScript thread while the read completes. Synchronous filesystem APIs can be reasonable during controlled startup or scripts, but they are risky in a hot server path where they delay unrelated requests.

Measure event-loop delay

Later performance lessons use these tools:

text
perf_hooks
monitorEventLoopDelay
eventLoopUtilization

They help determine whether the process is being starved by long JavaScript work or other event-loop delays. Use measurements to separate event-loop delay from thread-pool contention, network latency, database latency, and ordinary high CPU usage.

Do not diagnose event-loop blocking from “CPU seems high” alone. Inspect event-loop delay and the work on the relevant execution path, then correlate the result with request latency and other resource metrics.

Event-loop ordering experiment

Create this experiment:

js
import { readFile } from 'node:fs';

console.log('start');

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));

readFile(new URL(import.meta.url), () => {
  console.log('I/O');

  setTimeout(() => console.log('I/O timeout'), 0);
  setImmediate(() => console.log('I/O immediate'));
});

console.log('end');

Before running it, predict the broad relationships. Then run it on the Node version you support and compare the output with your reasoning.

The point is not to turn the exact output into an interview chant. Explain why the ordering context matters: synchronous logs finish first, nextTick and Promise work have special scheduling behavior, and the timer and immediate are registered from different contexts. Inside the I/O callback, the relationship between the newly scheduled immediate and timer is different from the relationship observed at the top level.

Common mistakes

  • believing that async means JavaScript itself is multi-threaded;
  • using synchronous APIs in hot server paths;
  • creating infinite nextTick or microtask loops;
  • launching unbounded Promise.all work;
  • assuming a timeout cancels the underlying request;
  • awaiting independent operations sequentially;
  • using an in-memory interval as a durable job scheduler;
  • increasing the thread pool blindly.

When debugging one of these mistakes, first identify the boundary involved: JavaScript execution, event-loop scheduling, a native worker pool, a downstream service, or application-level capacity. That usually leads to a better diagnosis than changing a timer or concurrency number at random.

Exercises

  1. Reproduce timer delay from event-loop blocking.
  2. Compare sequential and Promise.all timings.
  3. Build a fetch timeout with AbortController.
  4. Implement bounded concurrency.
  5. Saturate a controlled CPU loop and observe server responsiveness.
  6. Compare nextTick, microtask, timeout, and immediate.
  7. Explain which operations may use libuv pool.
  8. Design a queue-based solution for 100,000 background jobs.

For each exercise, record what you expected, what you observed, and which execution boundary explains the difference. In particular, distinguish a delayed callback from canceled work, and distinguish a limited worker pool from parallel JavaScript execution.

Mastery checklist

Explain:

  • call stack;
  • event loop;
  • phases at a practical level;
  • microtasks;
  • nextTick;
  • libuv thread pool;
  • blocking;
  • Promise concurrency;
  • cancellation;
  • bounded concurrency/backpressure.

You should be able to explain not only each term, but also where to look when a callback is late, a request is still consuming resources after a timeout, or a batch overwhelms its downstream dependency.

Official references

Reader page: /nodejs/lesson/119/node-async-runtime-event-loop-timers-microtasks-nexttick-and-libuv