119: 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, andprocess.nextTick; - understand the libuv thread pool at a practical level;
- identify blocking operations;
- reason about async ordering without cargo-cult memorization;
- avoid
nextTickstarvation; - use Promise/async patterns correctly;
- model bounded concurrency rather than launching unlimited work.
The main mental model
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
function c() {
console.log('c');
}
function b() {
c();
}
function a() {
b();
}
a();
Conceptual stack:
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
console.log('A');
setTimeout(() => {
console.log('timer');
}, 0);
Promise.resolve().then(() => {
console.log('promise');
});
console.log('B');
Expected broad idea:
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:
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
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:
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
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
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
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:
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:
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:
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
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:
const user = await getUser();
const teams = await getTeams();
If independent, this may create a waterfall.
Concurrent:
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
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
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:
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:
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
JSON.parse(veryLargeString);
is synchronous CPU work.
Regex
Pathological regular expressions can block the event loop.
Crypto
Some crypto APIs are synchronous:
crypto.pbkdf2Sync(...)
Avoid expensive sync variants on request paths.
Filesystem
readFileSync(...)
blocks.
Measure event-loop delay
Later performance lessons use:
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:
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
- Reproduce timer delay from event-loop blocking.
- Compare sequential and
Promise.alltimings. - Build a fetch timeout with AbortController.
- Implement bounded concurrency.
- Saturate a controlled CPU loop and observe server responsiveness.
- Compare nextTick, microtask, timeout, and immediate.
- Explain which operations may use libuv pool.
- 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.
