The Node.js event loop
Learn how Node.js phases, process.nextTick, setImmediate, microtasks, and event loop utilization shape asynchronous work.
- 01Trace Node phasesExplain timers, pending callbacks, poll, check, and close callbacks without treating them as JavaScript call-stack frames.
- 02Choose a queuePredict when nextTick, promise reactions, setImmediate, and timers are appropriate and where their order is stable.
- 03Measure pressureInterpret event loop utilization as active versus idle time and use it as a signal, not a timing promise.
One loop, many kinds of work
The Node.js event loop is libuv's repeated process for deciding which ready callbacks Node can run next. It lets one JavaScript thread start non-blocking I/O, return to other work, and later run the callback when the operating system reports completion.
The loop is not a magic speed-up. JavaScript callbacks still run one at a time on the main thread. A long callback blocks later timers, I/O callbacks, and requests from making progress, so queue order and small callbacks matter.
Node divides its loop into named phases. Around callback boundaries it also checks the Node-specific process.nextTick queue and JavaScript promise microtasks. Those queues have different jobs from the phases.
This lesson follows Scheduling work. It focuses on Node, while the published event loop, runtimes, and microtasks lessons give the shared browser-side foundation.
The libuv phases
A phase is a named part of one event-loop round with its own FIFO callback queue. Node does phase-specific work, runs callbacks in that queue, then moves on when the queue is empty or a system limit is reached.
const phases = ["timers", "pending callbacks", "poll", "check", "close callbacks"];
console.log(phases.join(" -> "));Line 1 names the phases this lesson uses. Timers handles due timer callbacks. Pending callbacks handles selected deferred system callbacks. Poll receives most I/O callbacks and can wait for I/O. Check runs setImmediate, and close callbacks handles selected close events.
| Phase | What it does | Keep in mind |
|---|---|---|
| timers | Runs callbacks for due setTimeout and setInterval timers. | A delay is a threshold, not an exact appointment. |
| pending callbacks | Runs some callbacks deferred from earlier system operations. | Most application I/O callbacks do not live here. |
| poll | Receives and runs many I/O callbacks and may wait for I/O. | The loop can move to check when an immediate is waiting. |
| check | Runs setImmediate callbacks after poll completes. | It is a separate phase from timers. |
| close callbacks | Runs some close events, such as abrupt socket closes. | It is not a general cleanup queue. |
A waiter walks the same round again and again. They check alarms, finish left-over jobs, take new orders and bring ready food, do right-after-round jobs, then clear tables from guests who left.
- In real life: Check the kitchen alarms
- In JavaScript: timers checks due timer callbacks
- In real life: Finish jobs left from last round
- In JavaScript: pending callbacks run
- In real life: Take orders and bring ready food
- In JavaScript: poll handles I/O
- In real life: Do right-after-round jobs
- In JavaScript: check runs immediates
Where the analogy stops: A real event loop has platform details and limits; the waiter story only gives the broad round order.
Do not read this as a promise that every round has work in every phase. Poll can wait when nothing is ready, and due timers are thresholds rather than exact scheduled moments.
Poll has an important decision to make. If an I/O callback is already ready, it runs that callback. If no I/O is ready but an immediate is waiting, Node can move to check. If neither is ready, poll may wait for the operating system to report I/O or until a timer threshold matters.
The labels describe queues, not five nested JavaScript functions. A callback can schedule more work while it runs. That new work joins its own queue, so the next visible callback depends on context as well as the broad phase map.
A modeled loop turn
Before reading real Node-only output, use a small model. It has plain phase names and walks one list in a fixed order. It deliberately leaves out platform internals, timer thresholds, and operating-system behavior.
Step through a small, instrumented model of a Node event-loop turn. It teaches ordering without claiming to reveal libuv internals.
script
{ phase: "timers", label: "tea timer" }, { phase: "poll", label: "order ready" }, { phase: "check", label: "after poll" },];const nextTicks = ["read note"];const promises = ["update screen"];console.log(runOneTurn(callbacks, nextTicks, promises).join("\n"));The replay calls the lesson's real runOneTurn function. After each modeled callback it drains the supplied nextTick labels first and promise labels second. This gives you a stable picture of the priorities without pretending a browser can expose libuv.
const phaseOrder = ["timers", "pending", "poll", "check", "close"]; function runOneTurn(callbacks, nextTicks = [], promises = []) { const output = []; for (const phase of phaseOrder) { for (const callback of callbacks.filter((item) => item.phase === phase)) { output.push(phase + ": " + callback.label); output.push(...nextTicks.map((label) => "nextTick: " + label)); output.push(...promises.map((label) => "promise: " + label)); } } return output;} console.log(runOneTurn([{ phase: "poll", label: "file ready" }], ["note"], ["then"]).join("\n"));poll: I/O
The model has 1 scheduled item. It reads nextTick and promise entries after each phase callback.
Add one kind of callback at a time. A timer, I/O callback, or immediate creates a phase callback. A nextTick note and a promise reaction appear after each phase callback in this teaching model.
Try adding a timer and an immediate, then add a nextTick note. The model keeps the phase callback order fixed so the new line is easy to see. Real Node also has timer thresholds and operating-system events, which is why this is a learning model rather than a scheduler you should copy.
The process.nextTick queue
The nextTick queue is a Node queue that runs after the current operation finishes and before the event loop continues. It is not one of the five displayed libuv phases, despite the name making it sound like a later loop turn.
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
console.log("sync");Line 1 queues the Node callback. Line 2 queues a promise reaction. Line 3 runs now, so the real Node output is sync, then nextTick, then promise. The lesson test proves this order in a child Node process.
Use process.nextTick sparingly when an API needs to finish the current stack before notifying its caller. A recursive chain can keep Node from reaching poll, which delays ready I/O. It is a priority note, not a general-purpose work scheduler.
A safe demonstration must stop. Imagine a function that reads note 1, queues note 2, then reads note 2 and queues note 3. Node reads all three notes before an immediate can run; after the third note, the loop can continue. An unbounded version never reaches that final step.
Promises are also urgent compared with later phase work, but Node drains process.nextTick before ordinary promise reactions at this boundary. Prefer promise APIs when you are continuing a promise chain. Use nextTick only when an API specifically needs that stronger ordering.
Imagine the waiter has a note in hand. They read it before taking even one more step around the room. If someone keeps handing them notes, they never return to the tables.
- In real life: The waiter finishes the current sentence
- In JavaScript: The current operation completes
- In real life: They read the note before walking
- In JavaScript: nextTick drains before the loop continues
- In real life: A pile of notes delays the round
- In JavaScript: Recursive nextTick can starve poll
Where the analogy stops: The queue has precise runtime rules; this analogy does not model every callback or microtask checkpoint.
setImmediate versus setTimeout
setImmediate schedules a callback for the check phase after poll. setTimeout schedules a callback after at least its delay threshold. Their names do not describe a universal race that application code should depend on.
import { readFile } from "node:fs";
readFile(process.execPath, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});Line 3 begins an I/O callback. Lines 4 and 5 queue a zero-delay timer and an immediate from inside that callback. In this I/O case, immediate always prints before timeout; the child-process test proves that stable order.
In the main module, calling both functions together can produce either order between runs. Do not assert either main-module order in a test. If you need work after an I/O callback, use setImmediate for the documented check-phase relationship.
The small difference in context is the whole lesson. At top level, startup timing can decide whether a zero-delay timer is ready when Node chooses work. Inside the file-read callback, Node has just finished poll work and can enter check, so the immediate has the stable first position.
“Zero delay” means eligible after a minimum threshold, not “interrupt everything now.” Keep that sentence nearby when reading logs. A busy callback, a full queue, or operating-system scheduling can all make the observed delay longer.
| API | When it runs | Use it for |
|---|---|---|
process.nextTick | After the current operation, before the loop continues. | Small API deferrals; never unbounded recursion. |
| Promise reaction | At the microtask checkpoint after the current callback. | Continue promise-based work. |
setImmediate | The check phase, after poll. | Run work after I/O when that relationship matters. |
setTimeout(..., 0) | Timers after its minimum delay threshold. | Schedule later work; do not use it for exact order. |
Microtasks between callbacks
A microtask is promise continuation work that Node checks after a callback. Since Node 11, Node runs the nextTick queue and microtasks after each setTimeout or setImmediate callback, before it starts the next callback in that queue.
setTimeout(() => {
console.log("timeout 1");
Promise.resolve().then(() => console.log("micro 1"));
}, 0);
setTimeout(() => {
console.log("timeout 2");
Promise.resolve().then(() => console.log("micro 2"));
}, 0);Lines 1 through 4 schedule the first timer and its promise reaction. Lines 6 through 9 schedule the second timer. The real output is timeout 1, micro 1, timeout 2, micro 2, not two timeouts followed by two microtasks.
Replay a teaching model of the checkpoint between callbacks. The separate Node-only snippet provides the real runtime evidence.
script
{ phase: "timers", label: "timeout 1" }, { phase: "timers", label: "timeout 2" },];const promises = ["micro 1"];console.log(runOneTurn(callbacks, [], promises).join("\n"));The replay uses the same small model as the playground. Its labels make the checkpoint visible; the Node-only snippet above is the real runtime evidence for current Node behavior.
The checkpoint happens after a callback, not only after an entire phase queue empties. That is why the first promise reaction fits between the two timer callbacks. This matters when one callback schedules a small follow-up that must settle before unrelated queued callbacks begin.
Keep the queues separate in your head. A timer callback is phase work. A promise reaction is microtask work created by code. A nextTick callback has Node-specific priority. Similar-looking asynchronous code can therefore have different ordering rules.
Event loop utilization
Event loop utilization, or ELU, is a Node measurement of how much sampled time the loop was active instead of idle. It is useful when a server feels busy, but it is not a stopwatch for one callback and it cannot tell you why work was expensive.
import { performance } from "node:perf_hooks";
const start = performance.eventLoopUtilization();
setImmediate(() => {
const report = performance.eventLoopUtilization(start);
console.log(Object.keys(report).sort().join(","));
});Line 3 takes a starting snapshot. The immediate creates a later observation point. Line 5 asks for the difference, and line 6 prints the stable keys active,idle,utilization. The test checks keys, never exact values or timings.
A high utilization value means the loop spent a high share of that interval active rather than idle. Investigate with CPU profiles, request rates, and slow callback measurements before deciding whether the cause is JavaScript, synchronous I/O, or a busy service.
Think of a ten-unit sample window. If the loop is active for eight units and idle for two, the simple ratio is 8 / (8 + 2), or 0.8. The runtime report has more detail than this teaching calculation, but the ratio explains why a value near one deserves attention.
Compare like with like: the same route, similar traffic, and a meaningful time window. A quiet server can have low utilization while one request is slow for another reason. A busy server can have high utilization while doing useful work, so pair ELU with latency and a CPU profile.
ELU is most useful as a trend across comparable periods. Pair it with latency and CPU evidence; a single high number is a signal to investigate, not a diagnosis.
Practical Node code
Most Node applications do not manually move callbacks between phases. You make the loop healthier by keeping request callbacks short, avoiding synchronous work in a hot server path, and using a worker or another service for CPU-heavy jobs.
Use setImmediate when work should follow I/O. Use promise APIs for promise-shaped async code. Reserve process.nextTick for narrow compatibility or API deferral cases, because it has stronger priority than ordinary loop progress.
For example, a request handler can start a database read and return control to Node. When the result is ready, keep its callback focused on validating data and responding. Do not parse a huge report, resize many images, or run a long regular expression in that callback; those jobs still occupy the one JavaScript thread.
When CPU work cannot become smaller, move it to a worker thread, a job queue, or another service. That does not make every order concern disappear, but it lets the event loop return to I/O and new requests instead of holding the server hostage to one expensive job.
pollcheckprocess.nextTick- A promise
.thenreaction - ELU
active - ELU
utilization
Sort the six cards by what each name describes in this lesson.
For deeper Node runtime design, the next lesson explains libuv & the thread pool. It separates kernel-backed I/O from work that libuv sends to its worker pool.
Common misconceptions
- “Node runs JavaScript in parallel.” Main-thread callbacks run one at a time, even while the system handles I/O elsewhere.
- “Zero means now.” A zero-delay timer is eligible after a threshold; other work can delay it.
- “nextTick is the next event-loop phase.” It is a separate high-priority queue checked before the loop continues.
- “Immediate always wins.” Only the I/O-callback case has the stable relationship taught here; main-module order varies.
- “High ELU proves a bug.” It proves the loop was active in a sampled period, not which code caused it.
When debugging ordering, first identify the context: main module, I/O callback, timer callback, or promise reaction. Then identify the queue. This is more reliable than guessing from API names.
Write only stable orders in tests. The nextTick-before-promise example and the I/O immediate-before-timeout example are stable. A top-level immediate-versus-timeout race is a useful warning, but it is not a product contract.
Practice exercises
Read the Node-only program. Type the comma-separated order it reports.
const output = [];
process.nextTick(() => output.push("nextTick"));
Promise.resolve().then(() => output.push("promise"));
output.push("sync");
setImmediate(() => console.log(output.join(",")));The reported order is sync,nextTick,promise. The immediate waits until after the current script and both priority queues.
Which phase runs a callback scheduled by setImmediate?
setImmediate callbacks run in the check phase.
Inside the readFile example, which label prints first?
setImmediate prints first when both callbacks are scheduled inside the I/O callback.
What output appears between the first and second timeout lines?
micro 1 runs between timeout 1 and timeout 2 because Node runs microtasks after each callback.
In plain words, what does a high ELU utilization value mean?
const report = { idle: 2, active: 8, utilization: 0.8 };
console.log(report.utilization > 0.7 ? "busy" : "idle");A high value says the event loop spent more of the interval active. Profile before choosing a fix.
Your server compresses a huge report during every request and requests queue up. What should it do with the CPU-heavy task?
Offload CPU-heavy work to worker threads or another service, or partition it. Do not hide it behind recursive nextTick callbacks.
Check your understanding
For each question, name the context first, then name the queue or phase. That habit is more dependable than treating asynchronous APIs as a single line.
Question 1 of 7Which phase runs
setImmediatecallbacks?Choose an answer to see the explanation.
Question 2 of 7What does this Node-only program print?
Read the code, then predictJavaScriptprocess.nextTick(() => console.log("nextTick")); Promise.resolve().then(() => console.log("promise")); console.log("sync");Choose an answer to see the explanation.
Question 3 of 7Where is
process.nextTickin the five-phase diagram?Choose an answer to see the explanation.
Question 4 of 7In an
fs.readFilecallback that schedules both callbacks, which is first?Choose an answer to see the explanation.
Question 5 of 7Since Node 11, what happens after each timer or immediate callback?
Choose an answer to see the explanation.
Question 6 of 7What can
performance.eventLoopUtilization()report?Choose an answer to see the explanation.
Question 7 of 7Why is recursive
process.nextTickrisky?Choose an answer to see the explanation.
Key takeaways
- Node's application-facing loop phases are timers, pending callbacks, poll, check, and close callbacks.
process.nextTickis outside those phases and runs before the loop continues.- Inside I/O,
setImmediateruns before a zero-delay timer; main-module order can vary. - Current Node runs nextTick work and promise microtasks between timer or immediate callbacks.
- ELU reports
idle,active, andutilizationfor a sampled interval.
Remember the one-liner.
The Node event loop is a repeated phase-based handoff from ready work to one JavaScript callback at a time.
When an ordering puzzle appears, make a small list: where was the callback scheduled, which queue owns it, and what work must finish first? That three-question habit turns an intimidating runtime diagram into a testable explanation. Context decides.
Coming next: libuv & the thread pool, where Node's kernel-backed I/O and worker-pool work are separated.