The Node.js event loop
Learn how Node.js phases, process.nextTick, setImmediate, microtasks, and event loop utilization shape asynchronous work.
- 01Follow one round of the loopName the five phases, timers, pending callbacks, poll, check, and close callbacks, and say what runs in each.
- 02Pick the right queuePredict when nextTick, promise callbacks,
setImmediate, and timers run, and know which orders are always the same. - 03Measure how busy the loop isRead event loop utilization as working time compared with waiting time, and use it as a reason to look closer, not as an exact timer.
One loop, many kinds of work
Node.js, or Node for short, runs JavaScript outside the browser, for example on a web server. A Node program often waits for slow things: a file to be read, a database to answer, or a message to arrive over the network. Reading and writing like this is called I/O, short for input and output.
Node doesn't stop and wait for I/O. It asks the operating system to do the I/O. It also keeps a callback: a function to run when the I/O is done. In the meantime, Node runs other code.
The Node.js event loop is the part of Node that decides which ready callback runs next. It goes round and round. On each round, it looks for finished work and runs the callbacks that are ready. A library called libuv, which is built into Node, runs this loop.
The event loop doesn't make JavaScript faster. Node runs your JavaScript on one thread. A thread is one line of work that does one thing at a time. So callbacks run one after another, never two at once. If one callback takes a long time, everything else waits: timers, I/O callbacks, and new requests. That is why the order of callbacks matters, and why short callbacks matter.
Node keeps waiting callbacks in lists called queues. A queue is a waiting line: the first one in is the first one out. There are three kinds:
- The phases: the named stops on each round of the loop. Each phase has its own queue.
- The
process.nextTickqueue: a Node-only queue that runs between callbacks. - The promise queue: callbacks from promises, like the function you pass to
.then.
Each kind runs at a different moment. This lesson shows when.
This lesson follows Scheduling work. It is about Node. The event loop, runtimes, and microtasks lessons cover the same ideas in the browser.
The five phases of one round
A phase is one named stop on a round of the event loop. Each phase has its own queue of callbacks. In each phase, Node:
- does that phase's own work, such as checking which timers are due;
- runs the callbacks in its queue, oldest first;
- moves on to the next phase when the queue is empty, or when it has run as many callbacks as it allows at one time.
const phases = ["timers", "pending callbacks", "poll", "check", "close callbacks"];
console.log(phases.join(" -> "));Line 1 lists the five phases in the order Node visits them. Line 2 joins them with arrows and prints them. Here is what each phase does:
timersruns callbacks fromsetTimeoutandsetIntervalwhose time is up.pending callbacksruns a few callbacks for system jobs left over from the last round.pollruns most I/O callbacks, such as "the file has been read". Node can also wait here for new I/O.checkruns callbacks fromsetImmediate.close callbacksruns some "closed" events, such as a network connection that closed suddenly.
close, the next round starts again at timers.The table below compares the phases. Read each row as: "this phase does this, and here is one thing to remember about it."
| Which phase | What it does | What to remember |
|---|---|---|
timers | It runs callbacks from setTimeout and setInterval whose time is up. | A delay is a minimum wait, not an exact time. |
pending callbacks | It runs a few callbacks for system jobs left over from the last round. | Most of your I/O callbacks don't run here. |
poll | It runs most I/O callbacks, and it can wait for new I/O. | If a setImmediate callback is waiting, Node moves on to check instead of waiting. |
check | It runs setImmediate callbacks, right after poll. | It is a different phase from timers. |
close callbacks | It runs some close events, such as a connection that closed suddenly. | It is not a place for all your clean-up work. |
A waiter walks the same round again and again. First they check the kitchen timers. Then they finish jobs left over from the last round. Then they take new orders and bring out food that is ready. Then they do the jobs saved for right after the orders. Last, they clear the tables of guests who left. Then they start again.
- In real life: Check the kitchen timers
- In JavaScript:
timersruns timers whose time is up - In real life: Finish jobs left over from the last round
- In JavaScript:
pending callbacksruns left-over system jobs - In real life: Take new orders and bring out food that is ready
- In JavaScript:
pollruns I/O callbacks - In real life: Do the jobs saved for right after the orders
- In JavaScript:
checkrunssetImmediatecallbacks - In real life: Clear the tables of guests who left
- In JavaScript:
close callbacksruns close events
Not every round has work in every phase. Node passes quickly through a phase with an empty queue. Also, a timer's delay is a minimum, not an exact time. A 100 ms timer runs at the first timers phase after 100 ms have passed.
Poll makes a choice each time it runs:
- If an I/O callback is ready, poll runs it.
- If no I/O is ready but a
setImmediatecallback is waiting, Node moves on tocheck. - If nothing is ready, poll waits for the operating system to report finished I/O. It stops waiting when a timer is due.
The phases are queues, not five functions inside each other. A callback can add new work while it runs. That new work goes into its own queue. So which callback runs next depends on where the work was added, not only on the list of phases.
A small model of one round
Real Node runs outside the browser, so this page can't show it live. Instead, here is a small model written in plain JavaScript. A model is a simple copy that keeps only the part you want to study. This model:
- visits the phase names in a fixed order;
- runs the callbacks you give it, phase by phase;
- leaves out real timer delays and the operating system.
Step through a small model of one round of the Node event loop. It shows the order of the queues, not what happens inside libuv.
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 phase callback, the model first adds the nextTick labels, then the promise labels. You'll meet these two queues in the next sections. The model shows the order of the queues. It can't show what happens inside 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 callback to run. After each phase callback, it adds every nextTick label, then every promise label.
Add one kind of callback at a time. A timer, an I/O callback, or an immediate adds a phase callback. A nextTick or a promise adds a label that the model prints after every phase callback.
Try this: add a timer and an immediate, then add a nextTick. The phase order stays the same, so the new lines are easy to spot. Real Node also has timer delays and operating-system events. That's why this is a model for learning, not code to copy into a real program.
The process.nextTick queue
process.nextTick(callback) is a Node-only function. It puts the callback in the nextTick queue. Node runs this queue as soon as the current code finishes, before the event loop moves on. The name sounds like "on the next round", but it isn't. The nextTick queue is not one of the five phases.
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
console.log("sync");Line 1 puts a callback in the nextTick queue. Line 2 makes a promise that is already finished, and its .then puts a callback in the promise queue. Line 3 runs right away. So the output is sync, then nextTick, then promise. The lesson test runs this code in a real Node process to check the order.
This order is for a CommonJS file. That is the older kind of Node file, which loads other files with require.
nextTick queue first, then the promise queue.Use process.nextTick rarely. It is for one case: a function must finish what it is doing before it tells its caller that something happened. Be careful: if a nextTick callback keeps adding another nextTick callback, Node never reaches poll. Then ready I/O waits forever.
A safe example has to stop. Picture a function that reads note 1 and adds note 2 to the nextTick queue. Then it reads note 2 and adds note 3. Node reads all three notes before any setImmediate callback can run. After note 3, nothing new is added, so the loop moves on. A version that never stops would block the loop forever.
Promise callbacks also run before the loop moves on. But in a CommonJS file, Node empties the nextTick queue before the promise queue. An ES module, a file that uses import and export, is different. Its top-level code already runs as a promise job, so there the promise callback can run before the nextTick callback.
For promise code, use promise tools like .then and await. Use process.nextTick only when a function really needs it.
Someone hands the waiter a note. The waiter reads it before taking one more step around the room. If people keep handing them new notes, they never get back to the tables.
- In real life: The waiter finishes the current sentence
- In JavaScript: The current code finishes
- In real life: They read the note before taking another step
- In JavaScript: Node runs the nextTick queue before the loop moves on
- In real life: A pile of notes keeps them from the tables
- In JavaScript: Endless nextTick callbacks keep Node from reaching poll
setImmediate versus setTimeout
Both functions run a callback later. They wait in different phases:
setImmediate(callback)puts the callback in thecheckphase, which comes right after poll.setTimeout(callback, delay)puts the callback in thetimersphase. It runs once at leastdelaymilliseconds have passed.
The names don't tell you which one runs first. The answer depends on where you call them.
import { readFile } from "node:fs";
readFile(process.execPath, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});Line 1 loads readFile, a function that reads a file. Line 3 starts reading a file. Here the file is the Node program itself, process.execPath. When the read is done, the callback on lines 3 to 6 runs in poll. Line 4 adds a timer with a delay of 0. Line 5 adds an immediate. Here, immediate always prints before timeout. The lesson test checks this order in a real Node process.
poll. The next phase is check, so immediate prints before timeout, which waits for the next round.Why? The callback ran in poll. The next phase is check, so the immediate runs there. The timer has to wait for the timers phase of the next round.
In the main file, outside any callback, it's different. If you call both functions at the top level, either one can print first. It changes from run to run, because it depends on how fast Node starts up. So never write a test that expects one order there. If you need work to run after an I/O callback, use setImmediate.
A delay of 0 doesn't mean "now". It means "as soon as possible after a short minimum wait". A busy callback, a long queue, or the operating system can all make the real wait longer.
The table below compares four ways to run code later. Read each row as: "this tool runs at this moment, and this is what it's for."
| Which tool | When it runs | What to use it for |
|---|---|---|
process.nextTick | Right after the current code, before the loop moves on. | Rare cases where a function must finish first. Never in an endless chain. |
| A promise callback | Right after the current callback, after the nextTick queue. | The next step of promise code. |
setImmediate | In the check phase, right after poll. | Work that should run after I/O. |
setTimeout(..., 0) | In the timers phase, after a short minimum wait. | Work for later. Don't use it to force an exact order. |
Microtasks between callbacks
A microtask is a small job that runs as soon as the current callback ends. Promise callbacks, like the function you pass to .then, are microtasks. Since Node 11, after each setTimeout or setImmediate callback, Node runs:
- the nextTick queue;
- then the microtasks;
- and only then the next callback in the phase.
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 to 4 add the first timer. When it runs, it prints timeout 1 and adds a promise callback. Lines 6 to 9 add a second timer that does the same. The real output is timeout 1, micro 1, timeout 2, micro 2. It is not both timeouts first and then both micros.
Replay a small model of what runs between two timer callbacks. The Node-only code above shows the real order.
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. It shows the order as labels. The Node-only code above is the real proof of how Node behaves today.
The microtasks run after each callback, not only at the end of a phase. That's why micro 1 fits between the two timers. This matters when a callback adds a small follow-up job: the follow-up finishes before other waiting callbacks start.
Keep the three kinds of callbacks apart:
- A timer callback is phase work.
- A promise callback is a microtask, added by your code.
- A nextTick callback is Node-only, and it runs before the microtasks.
So two pieces of code that look alike can run in different orders.
Event loop utilization
Event loop utilization, or ELU, is a report from Node about how busy the loop was over a stretch of time. It has three numbers:
active: the time the loop spent working;idle: the time the loop spent waiting with nothing to do;utilization: active time divided by the total time, a number from 0 to 1.
ELU helps when a server feels slow. But it doesn't time one callback, and it can't tell you which code was slow.
import { performance } from "node:perf_hooks";
const start = performance.eventLoopUtilization();
setImmediate(() => {
const report = performance.eventLoopUtilization(start);
console.log(Object.keys(report).sort().join(","));
});Line 1 loads performance, Node's tool for measuring time. Line 3 takes a first reading and calls it start. Line 4 adds a callback to the check phase, so the next lines run a little later. Line 5 asks for the change since start. Line 6 prints the report's names: active,idle,utilization. The test checks only these names, because the numbers change from run to run.
A high utilization means the loop was working, not waiting, for most of that time. To find out why, look further. Use a CPU profile, a record of which functions used the most time. Count the requests, and time the slow callbacks. Then decide whether the cause is your JavaScript, a file read that blocks, or a busy service.
Here is a simple example. Take 10 units of time. The loop is active for 8 units and idle for 2. Then the utilization is 8 / (8 + 2), which is 0.8. The real report has more detail, but this sum shows why a value close to 1 needs a closer look.
Compare like with like: the same route, similar traffic, and a similar stretch of time. 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 look at ELU together with response times and a CPU profile.
ELU is most useful when you compare similar stretches of time. Look at it together with response times and CPU data. One high number tells you to look closer. It doesn't tell you what is wrong.
Practical Node code
Most Node programs never move callbacks between phases by hand. You keep the loop healthy in three ways:
- Keep request callbacks short.
- Avoid blocking work in code that runs on every request. Blocking work stops everything until it is done, like
readFileSync. - Send heavy CPU work, like big calculations, to a worker thread or another service.
Pick the tool that fits the job:
- Use
setImmediatefor work that should run after I/O. - Use promises for promise code.
- Use
process.nextTickonly in rare cases, because it runs before the loop can move on.
For example, a request handler can start a database read and hand control back to Node. When the result is ready, keep that callback small: check the data and send the answer. Don't parse a huge report, resize many images, or run a long regular expression in that callback. Those jobs still use the one JavaScript thread.
If the CPU work can't be made smaller, move it to a worker thread, a job queue, or another service. The event loop is then free to handle I/O and new requests. One slow job no longer holds up the whole server.
pollcheckprocess.nextTick- A promise
.thencallback - ELU
active - ELU
utilization
Sort the six cards. Is each one a phase, a queue that runs between callbacks, or an ELU number?
The next lesson, libuv & the thread pool, goes deeper. It explains which I/O the operating system handles, and which work libuv sends to its own helper threads.
Common misconceptions
- "Node runs JavaScript in parallel." Your callbacks run one at a time on one thread, even while the operating system does I/O somewhere else.
- "A delay of 0 means now." A 0 ms timer still waits a short minimum time, and other work can make it wait longer.
- "nextTick is the next phase of the loop." It is a separate Node-only queue. Node runs it before the loop moves on.
- "setImmediate always runs first." That is only certain inside an I/O callback. In the main file, the order can change from run to run.
- "A high ELU proves there is a bug." It shows the loop was busy for that stretch of time. It doesn't show which code made it busy.
When an order puzzles you, first ask where the callback was added: in the main file, in an I/O callback, in a timer callback, or in a promise callback. Then ask which queue it went into. This works better than guessing from the function's name.
In tests, only check orders that are always the same. These two are: nextTick before a promise callback in a CommonJS file, and an immediate before a 0 ms timer inside an I/O callback. In the main file, the order of setImmediate and setTimeout is never certain, so don't test it.
Practice exercises
Read this Node-only program. Type the order it prints, with commas between the words.
const output = [];
process.nextTick(() => output.push("nextTick"));
Promise.resolve().then(() => output.push("promise"));
output.push("sync");
setImmediate(() => console.log(output.join(",")));The program prints sync,nextTick,promise. The immediate runs after the script ends and after both queues are empty, so it sees all three words.
Which phase runs a callback from setImmediate?
setImmediate callbacks run in the check phase.
In the readFile example, which label prints first?
setImmediate prints first when both callbacks are added inside the I/O callback.
In the two-timer example, what is printed between the first and the second timeout line?
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 means the event loop spent most of that time working, not waiting. Measure more, for example with a CPU profile, before you choose a fix.
Your server compresses a huge report on every request, and requests start to wait in line. What should it do with the CPU-heavy job?
Move the CPU-heavy work off the event loop: to worker threads or another service, or split it into small parts. Don't hide it in a chain of nextTick callbacks.
Check your understanding
For each question, first ask where the callback was added. Then name its queue or phase. This works better than treating every "run later" function the same way.
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 does
process.nextTickfit among the five phases?Choose an answer to see the explanation.
Question 4 of 7An
fs.readFilecallback adds both asetImmediateand asetTimeout(..., 0)callback. Which runs first?Choose an answer to see the explanation.
Question 5 of 7Since Node 11, what happens after each timer or
setImmediatecallback?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 it risky when a nextTick callback keeps adding another
process.nextTickcallback?Choose an answer to see the explanation.
Key takeaways
- The five phases of a round are timers, pending callbacks, poll, check, and close callbacks.
process.nextTickis not a phase. Its queue runs before the loop moves on.- Inside an I/O callback,
setImmediateruns before a 0 mssetTimeout. In the main file, the order can change. - Today's Node runs the nextTick queue and promise microtasks after every timer or immediate callback.
- ELU reports
idle,active, andutilizationfor a stretch of time.
Remember the one-liner.
The Node event loop goes round its phases and hands ready work to your JavaScript, one callback at a time.
When an order puzzles you, ask three questions. Where was the callback added? Which queue holds it? What must finish first? The answers explain the order, and you can check them with a test.
Coming next: libuv & the thread pool. It shows which I/O the operating system does, and which work goes to libuv's helper threads.