Node.js BasicsIntermediate8 min03 / 8

The Node Runtime & Event Loop

Learn how Node.js handles thousands of simultaneous connections using a single thread and a clever non-blocking event loop.

Imagine a busy coffee shop. A traditional server (think Java or PHP with threads) would assign a dedicated barista to each customer — that barista just stands there waiting while the espresso machine runs. Hire 10,000 customers and you need 10,000 baristas standing around. Node.js takes a completely different approach: one super-efficient barista takes orders, starts the espresso machine, then immediately takes the next order — checking back when the machine beeps. This is the essence of Node's single-threaded, non-blocking I/O model, and it lets a single Node process handle tens of thousands of connections at once.

#What Is the Node Runtime?

Node.js is a JavaScript runtime built on top of two key pieces:

  • V8 — Google's JavaScript engine (the same one in Chrome). It compiles and executes your JS code.
  • libuv — A C library that provides the event loop, thread pool, and async I/O primitives across operating systems.

When you run node server.js, V8 parses and runs your code, but anything that involves waiting — reading a file, making a network request, querying a database — is handed off to libuv, freeing V8 to keep running other JavaScript.

Basic Node CLI usage
# Check your Node version
node --version

# Run a file
node server.js

#Single-Threaded Does NOT Mean Slow

"Single-threaded" sounds like a limitation, but here's the insight: most server time is spent waiting, not computing. Waiting for a database to respond. Waiting for a file to load. Waiting for an HTTP response. Traditional thread-per-request servers are mostly managing sleeping threads — expensive in memory and context-switching overhead.

Node's single thread stays busy coordinating work rather than waiting. When an async operation finishes, its callback gets queued and processed on the next available event loop tick.

Think of it like

The Air Traffic Controller

Think of Node's event loop like an air traffic controller. There's one controller (single thread) who directs many planes (async operations) at once. The controller doesn't fly any plane themselves — they just give instructions and respond when a plane radios back. They're never idle waiting for one plane; they're always handling the next communication.

#Blocking vs Non-Blocking I/O

Here is the most important concept to internalize: there are two kinds of I/O calls in Node — blocking (synchronous) and non-blocking (asynchronous). Never use blocking I/O in production server code — it freezes the entire process for everyone.

Blocking vs non-blocking file reads
const fs = require('fs');

// BLOCKING — freezes the event loop until the file is fully read
const data = fs.readFileSync('big-file.txt', 'utf8');
console.log('Done (blocking):', data.length, 'chars');

// NON-BLOCKING — registers a callback and returns immediately
fs.readFile('big-file.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log('Done (non-blocking):', data.length, 'chars');
});
console.log('This prints BEFORE the file is read!');
Common mistake

Never Block the Event Loop in a Server

If you call fs.readFileSync or run a long CPU loop inside an HTTP request handler, every other request waits until it finishes. A single slow synchronous operation can bring your server to its knees. Use async APIs (fs.readFile, fs.promises.readFile, or await) in server code.

#How the Event Loop Works

The event loop is libuv's core scheduler. It runs in phases, processing different categories of callbacks in a specific order each iteration (called a tick). Here are the three most important phases for everyday Node work:

  1. Timers — Runs callbacks scheduled by setTimeout and setInterval whose delay has expired.
  2. Poll — Retrieves new I/O events (e.g. incoming network data, file reads finishing) and executes their callbacks. If nothing is ready yet, it waits here briefly.
  3. Check — Runs callbacks scheduled by setImmediate. These always run after the poll phase in the same tick.

Between every phase, Node drains two special microtask queues: process.nextTick() callbacks first, then resolved Promise callbacks (.then/await). Microtasks always cut to the front of the line.

Execution order reveals the event loop's priority rules
console.log('1 - synchronous');

setTimeout(() => console.log('2 - setTimeout (timers phase)'), 0);

setImmediate(() => console.log('3 - setImmediate (check phase)'));

Promise.resolve().then(() => console.log('4 - Promise microtask'));

process.nextTick(() => console.log('5 - nextTick microtask'));

console.log('6 - synchronous');

The output order is key to understanding the event loop: - Synchronous code runs to completion first. - process.nextTick fires before Promises (even though both are microtasks). - setTimeout(fn, 0) and setImmediate are macro-tasks — they wait for the next full loop phase. - setImmediate usually runs before setTimeout(fn, 0) when both are scheduled from within an I/O callback.

#Node vs Browser Event Loop

Both environments run JavaScript on a single thread with an event loop, but there are meaningful differences:

| Feature | Browser | Node.js | |---|---|---| | Global object | window | global / globalThis | | DOM / Web APIs | Yes | No | | setImmediate | No | Yes | | process.nextTick | No | Yes | | I/O | Fetch, XHR | fs, net, http modules | | Worker threads | Web Workers | worker_threads module |

In the browser, the event loop also handles rendering — painting frames between tasks. Node has no rendering concern, so its loop is purely about I/O and timers.

#The Thread Pool: Node's Hidden Workers

Here's something that surprises many developers: Node is not entirely single-threaded. libuv maintains a thread pool (4 threads by default) for operations that cannot be made asynchronous at the OS level — things like DNS lookups, some file system calls, and cryptography. These run on background threads, and when they finish, their callbacks are placed back on the event loop queue for your single JS thread to pick up. You never manage these threads directly; Node handles everything behind the scenes.

CPU-heavy crypto is offloaded to libuv's thread pool
const { createHash } = require('crypto');

// crypto.pbkdf2 uses the libuv thread pool — it does NOT block the event loop
crypto.pbkdf2('password', 'salt', 100000, 64, 'sha512', (err, key) => {
  if (err) throw err;
  console.log('Hash computed:', key.toString('hex').slice(0, 20), '...');
});

console.log('Event loop is free while hashing runs in thread pool');
Quick check

What happens to the Node.js event loop when you call `fs.readFileSync()` inside an HTTP request handler?

#Putting It All Together

Node's power comes from combining three ideas:

  1. One JS thread — no lock contention, no race conditions in your application code.
  2. Non-blocking async I/O — the thread is never idle waiting; it keeps processing callbacks.
  3. libuv's thread pool — truly blocking OS operations are silently offloaded.

This makes Node an excellent fit for I/O-heavy workloads like REST APIs, real-time apps, and streaming services. It's less suited to CPU-intensive work (image encoding, video transcoding) without using worker threads — because long-running JS code does block the event loop.

Key takeaways

  • Node.js runs your JavaScript on a single thread, but delegates I/O to libuv, which handles it asynchronously using OS primitives and a background thread pool.
  • The event loop cycles through phases (Timers → Poll → Check) each tick; microtasks (nextTick, Promises) always run between phases.
  • Never use synchronous/blocking APIs (e.g. readFileSync) in a server request handler — it halts the entire process for every connected client.
  • setImmediate runs in the Check phase (after I/O), while setTimeout(fn, 0) runs in the Timers phase — order can vary outside I/O callbacks.
  • Node is ideal for I/O-bound workloads; for heavy CPU work, reach for worker_threads to avoid starving the event loop.
Practice challenges
Test yourself · earn XP
0/4
Predict the output#1

The lesson explains the event loop's priority rules. Given synchronous code, a microtask, and two macro-tasks, what does this program print?

predict-output
console.log('A');

setTimeout(() => console.log('B'), 0);

Promise.resolve().then(() => console.log('C'));

process.nextTick(() => console.log('D'));

console.log('E');
Fix the bug#2

This code has a bug — what's wrong?

fix-bug
const fs = require('fs');

function handleRequest(req, res) {
  const data = fs.readFileSync('big-file.txt', 'utf8');
  res.end(data);
}
Fill in the blank#3

The lesson lists the three most important event loop phases in the order they run each tick. Fill in the missing phase name.

// Event loop phases each tick:
// 1. Timers  -> setTimeout / setInterval callbacks
// 2.    -> retrieves new I/O events (network data, file reads)
// 3. Check   -> setImmediate callbacks
Reorder the lines#4

Arrange these lines so they print in the exact order Node executes them, matching the event loop priority rules from the lesson.

1
setImmediate(() => console.log('5 - setImmediate check phase'));
2
Promise.resolve().then(() => console.log('3 - Promise microtask'));
3
process.nextTick(() => console.log('2 - nextTick microtask'));
4
console.log('1 - synchronous');
5
setTimeout(() => console.log('4 - setTimeout timers phase'), 0);
Your turn
Practice exercise

Create a small script that demonstrates the event loop order. It should: (1) log 'start', (2) schedule a setTimeout with 0ms delay that logs 'timer', (3) schedule a setImmediate that logs 'immediate', (4) resolve a Promise that logs 'promise', (5) use process.nextTick to log 'nextTick', and (6) log 'end'. Run it mentally and write down the expected output order as comments above each call, then verify by reading the output rules you learned.

Try it yourself — a starting point to build on:

starter.js
// Event Loop Order Explorer
// Before writing code, predict the output order in comments!

console.log('start');

// TODO: Add a setTimeout(fn, 0) that logs 'timer'

// TODO: Add a setImmediate that logs 'immediate'

// TODO: Add Promise.resolve().then(...) that logs 'promise'

// TODO: Add process.nextTick that logs 'nextTick'

console.log('end');

// Expected output order (fill this in before running):
// 1.
// 2.
// 3.
// 4.
// 5.
// 6.