The Event Loop
Reviewed & published by Brayan K
The event loop is the mechanism that lets single-threaded Node.js handle many operations at once — it runs your synchronous code first, then processes queued callbacks from timers, I/O, and promises without ever blocking.
Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
By the end of this lesson you'll understand why asynchronous code doesn't run in the order it's written, you'll be able to predict the output of a mix of setTimeout, process.nextTick, and Promises, and you'll know how to keep one thread responsive for thousands of users by never blocking the loop.
What You'll Learn in This Lesson
- What the event loop is and why Node is single-threaded
- Why synchronous code always runs before any callback
- The difference between microtasks and macrotasks
- How to predict the order of timers, nextTick, and Promises
- Why non-blocking I/O lets one thread serve many requests
- How to avoid freezing the loop with heavy synchronous work
1️⃣ Synchronous vs Asynchronous
Node runs all of your synchronous code — plain statements, one after another — before it touches anything you scheduled for later. When you call setTimeout, you aren't running code now; you're handing Node a callback to run after the current work finishes. That's why the timer below prints last, even with a delay of 0.
// Node runs your synchronous code top-to-bottom FIRST.
// Anything you schedule (like a timer) is queued and runs LATER,
// after the current synchronous code has finished.
console.log("1. Start"); // runs now (synchronous)
setTimeout(() => {
console.log("3. Timeout callback"); // queued — runs AFTER sync code
}, 0); // 0ms still does NOT mean "immediately"
console.log("2. End"); // runs now (synchronous)
// Even though the timer says 0ms, its callback waits until the
// synchronous code ("Start" and "End") has completely finished.Read it top to bottom: "1. Start" and "2. End" are synchronous, so they print immediately and in order. The setTimeout callback was queued the moment it was reached, but it can't run until the synchronous code is completely done — so "3. Timeout callback" comes last.
2️⃣ The Ordering: Microtasks Before Macrotasks
Once the synchronous code finishes, Node doesn't pick callbacks at random. It follows a strict priority. Microtasks — process.nextTick first, then resolved Promises — are drained completely. Only then does Node run macrotasks like setTimeout timers and setImmediate. So a Promise scheduled after a timer still runs before it.
// One tick of the event loop runs work in a strict order:
// 1) the synchronous code you wrote
// 2) MICROTASKS: process.nextTick(), then resolved Promises
// 3) MACROTASKS: timers (setTimeout) and setImmediate — and between
// THOSE TWO the order depends on where you scheduled them, see below
//
// Microtasks always drain before the loop moves on to the next
// macrotask — that is WHY nextTick and .then() print first.
console.log("A: sync"); // 1) synchronous
Promise.resolve().then(() => console.log("D: promise.then")); // microtask
process.nextTick(() => console.log("C: nextTick")); // microtask (first)
console.log("B: sync end"); // 1) synchronous
// The two macrotasks need care, because setTimeout(fn, 0) against
// setImmediate is the one pair with NO guaranteed order when you schedule
// them from the main module: it turns on how many milliseconds the process
// took to start. Run it eight times and you get four of each — measured,
// not guessed. Any page that promises you an order for those two at the top
// level is describing one run on one machine.
//
// Inside an I/O callback it is defined, and the reason is the loop's shape:
// I/O callbacks run in the poll phase, the check phase (setImmediate) comes
// straight after it, and timers only come round on the next turn.
require("node:fs").readFile(__filename, () => {
setTimeout(() => console.log("F: setTimeout — timers phase, next turn"), 0);
setImmediate(() => console.log("E: setImmediate — check phase, this turn"));
});Trace it: the two synchronous logs (A, B) print first. Then Node drains microtasks — process.nextTick (C) outranks the Promise (D). Finally the macrotasks run: the timer (E) then setImmediate (F). The lesson: nextTick before promise before timer.
- • Synchronous — runs immediately, in written order.
- • process.nextTick — highest-priority microtask; runs before Promises.
- • Resolved Promises (.then) — microtask; runs after nextTick, before any timer.
- • setTimeout / setImmediate — macrotasks; run only after all microtasks are drained.
3️⃣ Why Non-Blocking Matters
The real payoff of the event loop is that Node never sits idle waiting. When you start two slow operations, Node schedules both and keeps running — it doesn't freeze on the first one. They complete in the order they finish, not the order you wrote them. Here a fast task started second finishes before a slow task started first.
// Two "slow" tasks run side by side. Node does NOT wait for the
// first one before starting the second — it schedules both and
// keeps going. They finish in DELAY order, not in code order.
console.log("Kicking off two tasks...");
// Pretend this is a slow database read (300ms)
setTimeout(() => {
console.log("Task A done (was 300ms)");
}, 300);
// Pretend this is a fast cache read (100ms)
setTimeout(() => {
console.log("Task B done (was 100ms)");
}, 100);
console.log("Both tasks scheduled — not blocked!");
// Because the loop never blocks, the 100ms task finishes first
// even though it was written second. One thread, many tasks.Both synchronous lines print first, proving Node didn't block on either timer. Then the 100ms task (B) finishes before the 300ms task (A), even though A appears earlier in the code. This is the whole reason a single Node thread can serve thousands of simultaneous connections: while one request waits on the database, the loop is busy handling others.
Your turn. Predict the order first, then fill in the two ___ blanks so the labels match the expected output.
// 🎯 YOUR TURN — predict the order, then fill each ___ blank.
// Run it with: node order.js (or paste into a Node REPL)
console.log("one");
setTimeout(() => console.log("___"), 0); // 👉 which label prints LAST?
process.nextTick(() => console.log("___")); // 👉 microtask: prints before the timer
console.log("two");
// ✅ Expected output (synchronous first, then the microtask,
// then the timer macrotask last):
// one
// two
// nextTick
// timeoutMini-Challenge: Force the Order
No blanks this time — just a brief and an outline. Schedule three callbacks so the output appears in the exact order shown. Write it yourself, run it, and check your output against the comments.
// 🎯 MINI-CHALLENGE: schedule three callbacks so the output
// comes out in this exact order:
// sync now
// micro first
// macro last
//
// Outline (write the three lines yourself):
// 1. console.log a synchronous "sync now"
// 2. queue a MICROTASK that logs "micro first"
// (hint: process.nextTick or Promise.resolve().then)
// 3. queue a MACROTASK that logs "macro last"
// (hint: setTimeout(fn, 0))
//
// ✅ Expected output:
// sync now
// micro first
// macro last
// your code hereCommon Errors (and the fix)
- Assuming async code runs in written order — A console.log placed after a setTimeout or a Promise often prints first. Don't rely on top-to-bottom order for anything asynchronous; chain it with .then/await or put it in the callback.
- Blocking the loop with heavy synchronous loops — A giant for loop or fs.readFileSync on a big file freezes everything: no timers fire, no requests are served until it ends. Use async APIs or split the work into chunks / a Worker Thread.
- "Callback hell" — Deeply nested callbacks-inside-callbacks become unreadable and hard to error-handle. Flatten them with Promises and async/await, which you'll learn in later lessons.
- Forgetting that setTimeout is non-blocking — Code after a timer keeps running immediately; the callback fires later. If you need a value the timer produces, use it inside the callback, not on the next line.
- Expecting setTimeout(fn, 0) to run immediately — A 0ms timer is still a macrotask. It waits for all synchronous code and all microtasks first. For the soonest possible deferral, reach for process.nextTick or queueMicrotask.
Pro Tips
- 💡 Read it as three buckets: sync now → microtasks (nextTick, then Promises) → macrotasks (timers, setImmediate). Sorting any snippet into those buckets predicts the output every time.
- 💡 Keep each tick short: if a function runs for more than a few milliseconds of pure CPU work, it's probably blocking the loop. Break it up or offload it.
- 💡 Don't abuse process.nextTick: queuing nextTick from inside a nextTick can starve the loop and stop timers and I/O from ever running. Prefer setImmediate when you just want "after I/O".
📋 Quick Reference — Scheduling in Node
| Goal | JavaScript |
|---|---|
| Macrotask timer (after a delay) | setTimeout(fn, ms) |
| Run at end of the current phase | setImmediate(fn) |
| Microtask, as soon as possible | process.nextTick(fn) |
| Promise microtask | Promise.resolve().then(fn) |
| Don't block the loop | avoid long sync loops |
🎉 Lesson Complete!
- ✅ The event loop lets single-threaded Node handle many operations at once
- ✅ Synchronous code always runs first, before any queued callback
- ✅ Microtasks (process.nextTick, then Promises) drain before macrotasks
- ✅ setTimeout(fn, 0) is still a macrotask — it never runs immediately
- ✅ Non-blocking I/O is why one thread can serve thousands of requests
- ✅ Heavy synchronous work blocks the loop — keep each tick short
Practice quiz
When does Node run a setTimeout callback relative to your synchronous code?
- Before the synchronous code
- Only after the current synchronous code has finished
- Interleaved with the synchronous code
- Exactly at the millisecond requested, even mid-code
Answer: Only after the current synchronous code has finished. Synchronous code runs first; queued callbacks like timers run only after it finishes.
Which callback runs first among these microtasks and macrotasks?
- A resolved Promise .then()
- A setTimeout(fn, 0)
- A process.nextTick() callback
- A setImmediate() callback
Answer: A process.nextTick() callback. process.nextTick is the highest-priority microtask; it runs before Promises and any timer.
Between a resolved Promise .then() and a setTimeout(fn, 0), which runs first?
- The Promise .then(), because microtasks drain before macrotasks
- The setTimeout, because it was 0ms
- Whichever was written first in the file
- They run at the same time
Answer: The Promise .then(), because microtasks drain before macrotasks. Microtasks (Promises) drain completely before the loop moves to macrotasks like timers.
Why doesn't setTimeout(fn, 0) run immediately?
- 0ms is rounded up to 1000ms
- It runs immediately in Node, unlike browsers
- It only runs if there are no other timers
- It is a macrotask, so it waits for all sync code and microtasks first
Answer: It is a macrotask, so it waits for all sync code and microtasks first. Even at 0ms it is a timer macrotask that waits for synchronous code and the microtask queue.
Is the JavaScript you write in Node single-threaded or multi-threaded?
- Your JS runs on a single thread, with background threads handling slow I/O
- Fully multi-threaded with no single thread
- Single-threaded with no background help at all
- It depends on the number of CPU cores
Answer: Your JS runs on a single thread, with background threads handling slow I/O. Your JS and the event loop run on one thread; libuv and the OS handle I/O in the background.
Two timers are scheduled: A for 300ms and B for 100ms, with B written second. Which finishes first?
- A, because it was written first
- B, because it has the shorter delay
- They finish together
- Neither runs without await
Answer: B, because it has the shorter delay. The loop never blocks; callbacks fire in delay order, so the 100ms B finishes before the 300ms A.
Why is non-blocking I/O the key to Node serving thousands of connections on one thread?
- It uses one thread per connection
- It blocks until each request is done
- While one request waits on I/O, the loop keeps handling others
- It disables the event loop
Answer: While one request waits on I/O, the loop keeps handling others. Because the loop never sits idle waiting, it can handle other work while one request awaits I/O.
What happens if you run a huge synchronous loop on the main thread?
- Node automatically moves it to another thread
- It blocks the loop so timers stall and requests wait until it ends
- It runs as a microtask
- Nothing changes; the loop is unaffected
Answer: It blocks the loop so timers stall and requests wait until it ends. Heavy synchronous work blocks the single loop, freezing timers and incoming requests until it finishes.
Which order is correct for one tick of the event loop?
- timers, then Promises, then synchronous code
- Promises, then synchronous code, then nextTick
- setImmediate, then synchronous code, then nextTick
- synchronous code, then microtasks (nextTick then Promises), then macrotasks
Answer: synchronous code, then microtasks (nextTick then Promises), then macrotasks. Each tick runs sync code, drains microtasks (nextTick before Promises), then runs macrotasks.
What is a risk of abusing process.nextTick by queuing it from inside a nextTick?
- It can starve the loop so timers and I/O never run
- It speeds up I/O permanently
- It converts macrotasks to microtasks
- Nothing; it is always safe
Answer: It can starve the loop so timers and I/O never run. Recursively queuing nextTick can starve the loop, preventing timers and I/O from ever running.
Continue this course
- Previous: npm & package.json
- Next: Core Globals (process, __dirname) — Use process, __dirname, __filename, and other Node globals
- Quick reference: Node.js cheat sheet
Frequently asked questions
Is Node.js single-threaded or multi-threaded?
Your JavaScript runs on a single thread, and the event loop lives on that thread. That is why two pieces of JS never truly run at the same instant. Under the hood, though, Node uses a small pool of background threads (libuv) plus the operating system to handle slow work like file and network I/O. So your code is single-threaded, but the I/O it waits on is handled in the background and the results are delivered back to your single thread as callbacks.
What is the difference between a microtask and a macrotask?
Microtasks are high-priority callbacks the loop drains completely before doing anything else — these are process.nextTick callbacks (highest priority) and resolved Promise .then/.catch/.finally handlers. Macrotasks are the bigger phases of the loop: timers (setTimeout, setInterval), I/O callbacks, and setImmediate. After each macrotask, Node empties the entire microtask queue before moving on. That ordering is exactly why a Promise scheduled after a setTimeout still runs before it.
Why does setTimeout(fn, 0) not run immediately?
setTimeout always schedules its callback as a timer macrotask, even with a 0ms delay. Node first finishes all of the synchronous code in the current tick, then drains microtasks, and only then enters the timers phase where your 0ms callback finally runs. The 0 means "as soon as possible after the current work", not "right now". If you truly need to defer something to the very next opportunity, process.nextTick or queueMicrotask runs sooner than setTimeout(fn, 0).
How do I avoid blocking the event loop?
Never run long synchronous work (huge loops, big JSON.parse, synchronous crypto or fs.readFileSync) on the main thread, because while it runs nothing else can — timers stall and incoming requests wait. Instead, use the asynchronous versions of APIs (fs.readFile, not fs.readFileSync), break heavy CPU work into chunks, or move it to a Worker Thread or a separate process. Keeping each tick short is what lets one thread serve thousands of connections.