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

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.

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
//    timeout

Mini-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 here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Scheduling in Node

GoalJavaScript
Macrotask timer (after a delay)setTimeout(fn, ms)
Run at end of the current phasesetImmediate(fn)
Microtask, as soon as possibleprocess.nextTick(fn)
Promise microtaskPromise.resolve().then(fn)
Don't block the loopavoid long sync loops

🎉 Lesson Complete!

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

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.