Module: Nodejs
Nodejs·119·5 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 why Node handles many concurrent I/O operations on one JavaScript thread;
  • distinguish call stack, event loop, task queues, and microtasks;
  • understand timers, setImmediate, Promise microtasks, and process.nextTick;
  • understand the libuv thread pool at a practical level;
  • identify blocking operations;
  • reason about async ordering without cargo-cult memorization;
  • avoid nextTick starvation;
  • use Promise/async patterns correctly;
  • model bounded concurrency rather than launching unlimited work.

The main mental model

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 make CPU-heavy JavaScript parallel.

Call stack

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

function b() {
  c();
}

function a() {
  b();
}

a();

Conceptual stack:

text
a
b
c
console.log

When a function returns, its frame leaves the stack.

A synchronous infinite loop prevents the event loop from reaching other callbacks.

Basic ordering

js
console.log('A');

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

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

console.log('B');

Expected broad idea:

text
A
B
promise
timer

Promise reactions are microtasks, which run before returning to later event-loop phases.

Do not infer every Node scheduling rule from one snippet. Node has several phases and special nextTick behavior.

Event-loop phases

A practical simplified model includes phases such as:

text
timers
pending callbacks
poll
check
close callbacks

setImmediate() callbacks run in the check phase.

I/O completion is associated with poll-related processing.

Exact event-loop implementation details can change across Node/libuv versions. Learn the documented behavior and verify edge ordering on your supported version.

setTimeout

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

100 ms is a minimum-ish scheduling threshold, not a promise that code executes exactly at 100 ms.

If the JavaScript thread is blocked for 2 seconds, the callback waits.

Example:

js
const start = Date.now();

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

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

The callback cannot run during the loop.

setInterval

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

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

Intervals can drift when callbacks or event-loop delays are long.

For jobs requiring precise scheduling/business guarantees, use a scheduler/queue rather than assuming setInterval is durable scheduling.

If the process crashes, in-memory timers disappear.

setImmediate

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

setImmediate is useful when you want to schedule work in a later event-loop turn/check phase.

Do not replace normal Promise async code with setImmediate unless scheduling semantics matter.

Promise microtasks

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

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

Microtasks run after current JavaScript completes before moving on to later event-loop work.

Microtasks can also starve progress if code continually schedules more microtasks.

process.nextTick

Node has a special next-tick queue:

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

It is processed before the normal event loop continues and before ordinary microtask progression in Node's documented ordering.

Because it has very high priority, recursive next-tick scheduling can starve I/O:

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

loop();

Do not use nextTick as a generic async primitive.

Prefer Promise APIs, queueMicrotask, or normal event-loop scheduling according to semantics.

Why nextTick exists

Historically/usefully:

  • defer callback until after current call stack;
  • allow setup/listener attachment before an error/event;
  • maintain async API consistency in selected core/library patterns.

Application code rarely needs heavy use of it.

libuv thread pool

Some Node operations use libuv's worker pool rather than pure OS async APIs.

Examples commonly include selected:

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

That does not mean JavaScript callback code runs on those pool threads.

The expensive native operation can run there; completion callback returns to event-loop scheduling.

Thread-pool saturation

If an application launches many expensive thread-pool operations, they compete for a limited pool.

Example workload:

text
many password hashes
+
large filesystem work
+
compression

may increase latency.

Do not “fix” every latency issue by increasing UV_THREADPOOL_SIZE.

Measure workload and consider:

  • worker threads;
  • dedicated service;
  • async architecture;
  • queue;
  • capacity.

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 pauses the async function, not the entire Node process.

Other callbacks can run while the awaited operation is pending.

Sequential versus concurrent awaiting

Sequential:

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

If independent, this may create a waterfall.

Concurrent:

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

Use concurrency only when operations are truly independent.

Promise.all

Fails fast when one input rejects.

Use when all are required.

Promise.allSettled

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

Useful when you need individual outcomes.

Do not use allSettled to silently ignore critical failures.

Promise.race

Settles with first settled input.

Useful for some timeout/race patterns, but modern APIs often support AbortSignal directly.

A timeout Promise alone does not cancel underlying work.

Promise.any

Fulfills with first successful result; rejects with AggregateError if all reject.

Useful for redundant providers where first success wins.

Cancel unnecessary remaining work when possible.

Cancellation with AbortController

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);
}

Modern Node also provides useful AbortSignal helpers in supported versions; use documented APIs rather than home-grown cancellation flags.

Unbounded concurrency problem

Danger:

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

This can create:

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

Use bounded concurrency.

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;
}

Production packages/queues may provide richer semantics.

Backpressure across async systems

Bounded concurrency is one form of backpressure.

If producer creates work faster than consumer/database/API can handle it, you need:

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

Streams later provide native backpressure semantics for byte/object flow.

Blocking examples

JSON

js
JSON.parse(veryLargeString);

is synchronous CPU work.

Regex

Pathological regular expressions can block the event loop.

Crypto

Some crypto APIs are synchronous:

js
crypto.pbkdf2Sync(...)

Avoid expensive sync variants on request paths.

Filesystem

js
readFileSync(...)

blocks.

Measure event-loop delay

Later performance lessons use:

text
perf_hooks
monitorEventLoopDelay
eventLoopUtilization

to determine whether the process is starved.

Do not diagnose event-loop blocking from “CPU seems high” alone.

Event-loop ordering experiment

Create:

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');

Predict broad relationships, then run on your version.

Do not turn the exact output into an interview chant. Explain why ordering context matters.

Common mistakes

  • believing async means multi-threaded JavaScript;
  • using sync APIs in hot server paths;
  • infinite nextTick/microtask loops;
  • unbounded Promise.all;
  • timeout that does not cancel underlying request;
  • sequential independent awaits;
  • using in-memory interval as durable job scheduler;
  • increasing thread pool blindly.

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.

Mastery checklist

Explain:

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

Official references