Node.js: Code Outside the Browser / Node.js Internals and How it Works

The Event Loop

πŸ”„ The Event Loop

🧩 The Problem It Solves

You already know Node runs on a single thread. But a real app does dozens of things at once β€” handling requests, reading files, waiting on timers. How does one thread manage all of that without freezing?

The answer is the event loop. Think of it as a manager standing at a set of trays, constantly asking: "is anything ready for me to hand off to the one worker (the thread) I've got?" Nothing runs until the event loop decides it's that task's turn.

Two rules define what runs when:

  • Top-level code (anything not inside a callback) runs first, immediately, before the loop even starts.

  • Everything else β€” every callback β€” runs inside the event loop, once the loop decides it's time.

πŸ“’ Why Everything Is a Callback

Node is built around an event-triggered architecture. Instead of your code sitting and waiting for something to finish, you hand Node a callback and say "run this when the thing happens." Then Node moves on.

Things that "happen" and emit events:

  • 🌐 An HTTP request arrives

  • ⏰ A timer expires

  • πŸ“ A file finishes reading

The event loop's whole job is: πŸ‘‚ listen for these events β†’ πŸ“₯ pick them up as they're ready β†’ ▢️ run their callback. Heavy work gets quietly handed off to the thread pool along the way, so the main thread never gets stuck waiting.


πŸ—οΈ Inside the Loop: Four Phases, Not One Queue

It's tempting to imagine one long line of callbacks waiting their turn. That's wrong. The event loop actually cycles through four separate phases, each with its own queue β€” like four checkout lines, processed one at a time, in a fixed order. A phase fully empties its queue before the loop moves to the next.

Every cycle, in order: Timers β†’ I/O β†’ setImmediate β†’ Close

#

Phase

Handles

Frequency in Real Apps

1️⃣

Expired Timers

setTimeout() / setInterval() callbacks

Occasional

2️⃣

I/O Polling & Callbacks

Networking & file I/O

πŸ”₯ ~99% of app code

3️⃣

setImmediate()

Runs right after I/O polling

Rare / advanced

4️⃣

Close Callbacks

Cleanup β€” server/socket shutdowns

Rare

1️⃣ Expired Timers

What it does: fires callbacks whose timer has run out.

console.log('Start');
setTimeout(() => console.log('Timer callback (Phase 1)'), 0);
console.log('End');
Start
End
Timer callback (Phase 1)

Takeaway: even at 0ms, the callback waits its turn β€” top-level code always finishes first.

2️⃣ I/O Polling & Callbacks β€” where the real work happens

What it does: handles networking and file I/O. This is the phase where ~99% of real Node app code executes, since most apps are built around serving requests and touching files.

const fs = require('fs');
console.log('Start');
fs.readFile('file.txt', () => console.log('File read callback (Phase 2)'));
console.log('End');
Start
End
File read callback (Phase 2)

Takeaway: the read happens in the background (often on the thread pool); the callback only fires once the loop reaches this phase.

3️⃣ setImmediate()

What it does: a special timer designed to run right after I/O polling finishes β€” "as soon as I/O is done, before anything else."

fs.readFile('file.txt', () => {
  setTimeout(() => console.log('setTimeout'), 0);
  setImmediate(() => console.log('setImmediate'));
});
setImmediate
setTimeout

Takeaway: called inside an I/O callback, setImmediate() always beats setTimeout(0) β€” the loop moves straight from I/O into the setImmediate phase, while the timer phase is a whole lap away.

4️⃣ Close Callbacks

What it does: cleanup work β€” e.g. a server or socket shutting down.

const server = require('net').createServer();
server.on('close', () => console.log('Server closed (Phase 4)'));
server.listen(3000, () => server.close());
Server closed (Phase 4)

Takeaway: rarely touched in everyday app code, but this is exactly when 'close' handlers fire.

πŸ” Seeing All Four Phases in One Run

console.log('1: Top-level code');
setTimeout(() => console.log('2: Timer phase'), 0);
fs.readFile(__filename, () => {
  console.log('3: I/O phase');
  setImmediate(() => console.log('4: setImmediate phase'));
});
console.log('5: Top-level code (end)');
1: Top-level code
5: Top-level code (end)
2: Timer phase
3: I/O phase
4: setImmediate phase

Pattern to notice: all top-level code runs first, then the loop works through Timers β†’ I/O β†’ setImmediate β†’ Close, one fully-drained queue at a time.


⚑ The Two Queues That Cut in Line

There are two extra queues that don't play by the four-phase order β€” they jump in immediately after whichever phase just finished, before the loop moves to the next phase:

  • πŸš€ process.nextTick() β€” highest priority, runs right after the current phase

  • 🧩 Microtasks (mainly resolved Promises) β€” same idea, runs right after the current phase

Example: if a Promise resolves while a timer callback is executing, the Promise's callback runs immediately after that timer callback finishes β€” it doesn't wait for I/O, setImmediate, or Close to happen first.

Function

Runs When

πŸš€ process.nextTick()

Right after the current phase β€” highest priority

⚑ setImmediate()

Specifically after the I/O phase

These are advanced, edge-case tools β€” worth recognizing, rarely needed directly.


⏱️ One Lap = One "Tick" β€” and When the App Exits

A full pass through all four phases (plus the priority queues) is called a tick. After each tick, Node asks one question: is there still pending work?

  • ❌ No pending timers or I/O β†’ the app exits

  • βœ… Something's still pending β†’ the loop runs another tick

This is exactly why a server stays alive after starting: listening for HTTP requests is an ongoing I/O task, so there's always something pending, so the loop never stops. Same logic applies to a background file read β€” the app stays up until it resolves.


🌟 Why Any of This Matters

Node has to serve potentially millions of users through a single thread, and the event loop is the mechanism that makes that possible β€” by rapidly cycling through phases and always finding the next bit of ready work instead of sitting idle.

βœ… Upside

⚠️ Trade-off

Lightweight, scalable β€” no new thread per user

Block the thread once, and every user feels it

For contrast: PHP/Apache spins up a new thread per user β€” heavier on resources, but one slow request can't take down anyone else's. Node chooses efficiency and puts the burden on you to not block the thread.


βœ… Your Job: Don't Block the Loop

Since blocking hurts everyone, follow these:

  1. 🚫 Never use sync versions of fs, crypto, zlib inside callbacks β€” sync calls are fine only in top-level code, which runs before the loop even starts

  2. 🧠 Avoid heavy calculations directly inside callback code

  3. πŸ“¦ Watch out for huge JSON β€” JSON.parse/stringify slow down at scale

  4. πŸ” Avoid complex regex (nested quantifiers, backreferences)

For genuinely heavy work later on: offload manually to the 🧡 thread pool or use πŸ‘Ά child processes.


πŸ”‘ Key Takeaways

πŸ“Œ Concept

βœ… Explanation

Event loop

Runs all callback code β€” Node's "heart"

Event-triggered

Events (requests, timers, file reads) trigger callbacks

4 phases

Timers β†’ I/O β†’ setImmediate β†’ Close, each with its own queue

I/O phase

Where ~99% of app code runs

nextTick/Microtasks

Jump the queue β€” run right after current phase

Tick

One full loop cycle

Continue/exit

Loop continues if timers/I/O are pending

Why it matters

Enables async, non-blocking code on a single thread

Blocking risk

Freezes the app for all users at once

Best practices

Avoid sync fs/crypto/zlib in callbacks, heavy compute, huge JSON, complex regex