The Event Loop, Call Stack, and Non-Blocking I/O — Deep Dive

▶ Try It Yourself

The event loop is the mechanism that makes Node.js capable of handling thousands of simultaneous connections despite running on a single thread. Understanding it deeply — not just “Node.js is asynchronous” — lets you predict code execution order, avoid subtle bugs, write efficient servers, and explain confidently why a blocking operation in the wrong place can bring a Node.js server to a halt. This lesson is a Node.js-specific deep dive into the event loop phases, the libuv thread pool, and the difference between the Node.js and browser event loop models.

The Node.js Event Loop Phases

Phase Queue Processes
1 — timers Timer callbacks setTimeout and setInterval callbacks whose delay has expired
2 — pending callbacks System callbacks I/O callbacks deferred from the previous loop iteration (TCP errors etc.)
3 — idle / prepare Internal Internal Node.js use only
4 — poll I/O callbacks Retrieve new I/O events; execute I/O-related callbacks (fs, net, http)
5 — check setImmediate setImmediate callbacks
6 — close callbacks Close events Socket and handle close events (socket.on('close', ...))

Microtask Queues — Run Between Every Phase

Queue Source Priority
nextTick queue process.nextTick(fn) Highest — drains before any other queue
Promise microtask queue Promise.then/catch/finally, queueMicrotask Second — drains after nextTick queue

libuv Thread Pool

Feature Detail
Default size 4 threads (set via UV_THREADPOOL_SIZE env var, max 128)
Used by File system operations, DNS resolution, crypto operations, zlib
Not used by Network I/O (TCP/UDP) — handled directly by OS async syscalls
Implication More than 4 concurrent file operations queue and wait for a thread
Note: process.nextTick() is NOT part of the event loop phases — it runs after the current operation completes and before the event loop continues to the next phase, draining completely on every boundary. This makes it higher priority than Promises. Overusing process.nextTick() with recursive calls can starve the event loop just like an infinite microtask loop. Use it sparingly — prefer Promise.resolve().then() or queueMicrotask() for most async-but-immediate patterns.
Tip: The libuv thread pool processes file system operations. If your server handles many concurrent file reads or writes, the default pool size of 4 may become a bottleneck. Increase it with UV_THREADPOOL_SIZE=16 node server.js or set it in code before any I/O: process.env.UV_THREADPOOL_SIZE = 16. For network I/O (HTTP requests, TCP connections, MongoDB queries), the thread pool is not used at all — the OS handles it natively.
Warning: setTimeout(fn, 0) does not mean “run immediately” in Node.js either. Timers in Node.js have a minimum resolution of approximately 1ms, and the timer phase only runs callbacks whose delay has actually expired. In I/O callbacks, setImmediate is more reliable than setTimeout(fn, 0) for deferring to the next iteration because setImmediate always runs in the check phase of the current loop iteration, while a timer with delay 0 may fire in the timers phase of the next iteration.

Event Loop Execution Order — Complete Example

// event-loop-order.js — run with: node event-loop-order.js
const fs = require('fs');

console.log('1 — synchronous start');

// nextTick — runs before any I/O callbacks, before Promises
process.nextTick(() => console.log('2 — process.nextTick'));

// Promise microtask — runs after nextTick
Promise.resolve().then(() => console.log('3 — Promise.resolve().then'));

// setTimeout with 0 delay — timer phase
setTimeout(() => console.log('6 — setTimeout(0)'), 0);

// setImmediate — check phase (after poll)
setImmediate(() => console.log('5 — setImmediate'));

// File I/O — poll phase callback
fs.readFile(__filename, () => {
    console.log('4 — fs.readFile callback (poll phase)');

    // Inside an I/O callback, setImmediate runs BEFORE setTimeout
    setTimeout(() =>   console.log('8 — setTimeout inside I/O'), 0);
    setImmediate(() => console.log('7 — setImmediate inside I/O'));  // guaranteed first
});

console.log('1b — synchronous end');

// OUTPUT ORDER:
// 1 — synchronous start
// 1b — synchronous end
// 2 — process.nextTick
// 3 — Promise.resolve().then
// 4 — fs.readFile callback (poll phase)     ← may appear before 5/6
// 5 — setImmediate                           ← order vs setTimeout varies outside I/O
// 6 — setTimeout(0)
// 7 — setImmediate inside I/O               ← guaranteed before setTimeout inside I/O
// 8 — setTimeout inside I/O

Non-Blocking I/O in Practice

// non-blocking-demo.js
const fs   = require('fs');
const http = require('http');

// ── Blocking vs Non-Blocking File Read ───────────────────────────────────

// BLOCKING — synchronous — DO NOT USE IN SERVERS
console.time('sync read');
const data = fs.readFileSync('./large-file.txt', 'utf8');   // blocks entire thread
console.timeEnd('sync read');

// NON-BLOCKING — asynchronous — CORRECT for servers
console.time('async read start');
fs.readFile('./large-file.txt', 'utf8', (err, data) => {
    if (err) throw err;
    console.log('File read complete:', data.length, 'chars');
});
console.timeEnd('async read start');   // logs almost instantly — before file is read
console.log('This runs while the file is being read');

// ── Promise-based async (modern style) ───────────────────────────────────
async function readConfig() {
    const data = await fs.promises.readFile('./config.json', 'utf8');
    return JSON.parse(data);
}

// ── Parallel I/O — run multiple async operations simultaneously ───────────
async function loadMultipleFiles() {
    console.time('parallel');
    const [file1, file2, file3] = await Promise.all([
        fs.promises.readFile('./a.txt', 'utf8'),
        fs.promises.readFile('./b.txt', 'utf8'),
        fs.promises.readFile('./c.txt', 'utf8'),
    ]);
    console.timeEnd('parallel');   // completes in max(a, b, c) time, not a + b + c
    return [file1, file2, file3];
}

// ── Demonstrating the single thread — never block it ─────────────────────
const server = http.createServer((req, res) => {
    if (req.url === '/fast') {
        res.end('Fast response');                   // returns immediately
    }

    if (req.url === '/blocking') {
        // Simulate CPU-heavy work — THIS BLOCKS ALL OTHER REQUESTS
        const start = Date.now();
        while (Date.now() - start < 2000) {}       // busy-wait 2 seconds
        res.end('Slow response — blocked everyone');
    }

    if (req.url === '/nonblocking') {
        // Correct: delegate to setTimeout to yield control
        setTimeout(() => res.end('Non-blocking response'), 2000);
        // Event loop remains free during the 2-second wait
    }
});

server.listen(3000);

How It Works

Step 1 — Synchronous Code Runs First, Always

Every time Node.js starts a script or processes an event, it runs all synchronous code first — from top to bottom, function calls and all — until the call stack is empty. Only then does it check the microtask queues and then begin the next event loop phase. No async callback, timer, or I/O completion can interrupt synchronous code in progress.

Step 2 — nextTick and Promises Drain Between Every Phase

After each event loop phase completes (and after each individual callback within a phase in Node.js 11+), Node.js drains the nextTick queue completely, then drains the Promise microtask queue completely, before moving to the next phase. This means nextTick callbacks added inside a nextTick callback run before any Promises, and Promises added during their own callbacks keep running until the queue is empty.

Step 3 — The Poll Phase Is the Heart of I/O

The poll phase is where most of the work happens. Node.js asks the OS for any completed I/O events, then executes their callbacks. If there is nothing to process and there are setImmediate callbacks waiting, it moves on immediately. If the poll queue is empty and there are no immediate callbacks, it blocks here waiting for I/O — this is how Node.js efficiently sleeps between requests without busy-waiting.

Step 4 — setImmediate vs setTimeout(fn, 0)

Outside an I/O context, the order of setImmediate vs setTimeout(fn, 0) is non-deterministic — it depends on the performance of the machine. Inside an I/O callback (inside a file read or network callback), setImmediate always runs before setTimeout(fn, 0) because setImmediate belongs to the check phase which immediately follows the poll phase where the I/O callback is running.

Step 5 — libuv Thread Pool Handles File System Operations

Network I/O (HTTP requests, TCP connections, MongoDB) goes directly through the OS’s async I/O mechanisms (epoll/kqueue). These do not use threads — the OS notifies libuv when data is available. File system operations are different — most OS file APIs are synchronous at the system level, so libuv uses a thread pool to run file operations in background threads without blocking the JavaScript thread.

Real-World Example: Server Concurrency Test

// concurrency-demo.js
// Start this server and hit /fast and /blocking simultaneously in Postman
// to observe how blocking affects ALL concurrent requests

const http = require('http');

let requestCount = 0;

const server = http.createServer(async (req, res) => {
    const id      = ++requestCount;
    const url     = req.url;
    const start   = Date.now();

    console.log(`[${id}] ${url} — received`);

    if (url === '/fast') {
        // Non-blocking: each request handled immediately
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ id, url, time: Date.now() - start }));

    } else if (url === '/async-delay') {
        // Non-blocking: event loop is FREE during the 2s wait
        await new Promise(resolve => setTimeout(resolve, 2000));
        res.writeHead(200);
        res.end(JSON.stringify({ id, url, time: Date.now() - start }));

    } else if (url === '/blocking') {
        // BLOCKING: event loop FROZEN for 2 seconds — all other requests stall
        const end = Date.now() + 2000;
        while (Date.now() < end) {}   // busy-wait
        res.writeHead(200);
        res.end(JSON.stringify({ id, url, time: Date.now() - start }));
    }

    console.log(`[${id}] ${url} — completed in ${Date.now() - start}ms`);
});

server.listen(3000, () => console.log('Listening on port 3000'));

// Test with: open two browser tabs simultaneously
// Tab 1: http://localhost:3000/blocking
// Tab 2: http://localhost:3000/fast
// Observe: Tab 2 (fast) waits for Tab 1 (blocking) to complete!

// vs:
// Tab 1: http://localhost:3000/async-delay
// Tab 2: http://localhost:3000/fast
// Observe: Tab 2 responds immediately even while Tab 1 is waiting

Common Mistakes

Mistake 1 — Recursive process.nextTick causing infinite loop

❌ Wrong — starves the event loop completely:

function recursiveNextTick() {
    process.nextTick(recursiveNextTick);   // I/O and timers never get to run
}
recursiveNextTick();

✅ Correct — use setImmediate for recursive async patterns to yield to I/O:

function processItems(items, index = 0) {
    if (index >= items.length) return;
    process(items[index]);
    setImmediate(() => processItems(items, index + 1));   // yields to I/O each iteration
}

Mistake 2 — Assuming setTimeout(fn, 0) fires immediately after current code

❌ Wrong assumption in Node.js (and browsers):

let value = 0;
setTimeout(() => console.log(value), 0);   // assumes this runs "right after"
value = 42;
// logs 42 — the assignment runs first (synchronous)
// but this is because sync code runs to completion — NOT because setTimeout is immediate

✅ Understand: setTimeout fires in the timers phase, never interrupting synchronous code.

Mistake 3 — Not increasing UV_THREADPOOL_SIZE for file-heavy apps

❌ Wrong — default pool of 4 threads bottlenecks a server doing many concurrent file reads:

// 10 concurrent file reads — only 4 run simultaneously, 6 queue and wait
const reads = Array.from({ length: 10 }, (_, i) =>
    fs.promises.readFile(`./file-${i}.txt`)
);
await Promise.all(reads);   // takes ~3x longer than optimal

✅ Correct — increase the pool before any I/O code runs:

// At the very top of your entry point, before any requires
process.env.UV_THREADPOOL_SIZE = '16';
// Now 10 concurrent file reads all run simultaneously

Quick Reference

API Phase / Queue Use When
process.nextTick(fn) nextTick queue — before I/O Must run before any I/O callbacks in current tick
Promise.resolve().then(fn) Microtask queue — after nextTick Standard async-but-immediate pattern
setImmediate(fn) Check phase — after I/O Defer to next loop iteration without timer delay
setTimeout(fn, 0) Timers phase — next iteration Minimum delay — less predictable than setImmediate
fs.readFile(path, cb) libuv thread pool → poll phase All file system async operations
http.request() OS async I/O → poll phase All network I/O — no thread pool used

🧠 Test Yourself

Inside an I/O callback (e.g. inside fs.readFile), what is the guaranteed execution order of setImmediate vs setTimeout(fn, 0)?





▶ Try It Yourself