How Node.js Works

Published by Programming Line in Node.js. Last updated .

Node.js lets you run JavaScript outside a browser, on a server, using a single main thread that never blocks waiting for slow work like disk reads or network calls. That single design decision — one thread, non-blocking I/O, everything else deferred to callbacks, promises, or a background thread pool — is why Node can serve thousands of concurrent connections with modest hardware. Understanding how Node pulls this off is not academic: it explains why one slow synchronous line of code can freeze an entire server, and why async code sometimes runs in an order you did not expect.

Overview: What Node.js Actually Is

Node.js is not a language and not just “JavaScript on the server” — it is a C++ program that embeds Google’s V8 engine (the same JavaScript engine used in Chrome) and wraps it with a library called libuv. V8 compiles and executes your JavaScript. Everything Node adds on top — the fs, http, and net modules, timers, the event loop itself — comes from libuv and Node’s own C++ bindings, not from JavaScript or V8.

V8: the JavaScript engine

V8 parses your code, JIT-compiles it to machine code, and manages memory (the garbage collector). It runs your JavaScript on a single thread, one line at a time, exactly like a browser tab does. V8 has no idea what a file or a socket is — it only executes functions.

libuv: the event loop and the thread pool

libuv is a C library that gives Node three things browsers don’t need: an event loop, a thread pool, and cross-platform async I/O bindings to the operating system (epoll on Linux, kqueue on macOS, IOCP on Windows). When you call fs.readFile(), Node doesn’t read the file on the main thread. It hands the request to libuv, which either asks the OS to notify it when the file is ready (true async I/O, used for most networking) or — for things the OS can’t do asynchronously, like most filesystem calls, DNS lookups via dns.lookup(), and some crypto functions — hands the work to a background thread pool (4 threads by default, configurable via the UV_THREADPOOL_SIZE environment variable). Either way, your JavaScript callback only runs later, back on the single main thread, when the event loop reaches the right phase.

Single-threaded, but not single-threaded

This is the detail that trips people up: your JavaScript always runs on one thread. Two callbacks never run at the same instant, and you never need a mutex around a shared JS object. But Node the runtime uses multiple OS threads under the hood (the thread pool, plus threads V8 uses for garbage collection and compilation) to keep that one JS thread from ever waiting idly on I/O. The parallelism is real; it just never touches your JavaScript directly unless you explicitly opt in with worker_threads.

Syntax: The Event Loop Phases and the Callback Convention

There is no “syntax” to how the event loop works — but there is a fixed order it always follows, and a calling convention Node’s own APIs use consistently. Both are worth memorizing.

Phase What runs here
Timers Callbacks scheduled by setTimeout() and setInterval() whose delay has elapsed
Pending callbacks Certain system-level callbacks deferred from the previous loop iteration (e.g. some TCP errors)
Poll Retrieves new I/O events and runs their callbacks (file reads, incoming sockets); the loop can pause here if there is nothing else to do
Check Runs setImmediate() callbacks, immediately after the poll phase finishes
Close callbacks Runs close-event callbacks, e.g. socket.on("close", ...)

Between every single callback — not just once per phase — Node fully drains two queues in order: first process.nextTick() callbacks, then Promise microtasks (the callbacks passed to .then(), or code after an await). Only once both are empty does the loop move on. This is why process.nextTick() and resolved promises always run before the next timer or I/O callback, no matter how short the timer’s delay is.

Node’s own asynchronous APIs also follow one convention consistently: the error-first callback. The first argument is always an error (or null), the rest are results.

function handleResult(err, data) {
  if (err) {
    console.error("Something went wrong:", err.message);
    return;
  }
  console.log("Success:", data);
}

The modern promise-based APIs (fs/promises, util.promisify) skip this pattern and let you use try/catch with async/await instead, which is why the rest of this course prefers them for new code.

Examples

Example 1: Non-blocking I/O in practice

Two file reads are started back to back. Neither one pauses the thread — the synchronous code below them runs immediately, and both callbacks fire later.

const fs = require("node:fs");

console.log("1: reading two files...");

fs.readFile(__filename, "utf8", () => {
  console.log("3: first read finished");
});

fs.readFile(__filename, "utf8", () => {
  console.log("4: second read finished");
});

console.log("2: this line runs before either file finishes");

Output:

1: reading two files...
2: this line runs before either file finishes
3: first read finished
4: second read finished

Lines 1 and 2 always print first because they are synchronous. Both readFile calls are dispatched to libuv’s thread pool concurrently, so — while they will almost always finish in submission order for two identical local reads — the pool does not strictly guarantee ordering between independent background tasks the way the main thread guarantees ordering between synchronous lines.

Example 2: nextTick, microtasks, and timers

This example proves the ordering rule from the Syntax section: synchronous code first, then nextTick, then promise microtasks, then timers.

console.log("A: synchronous start");

setTimeout(() => {
  console.log("E: setTimeout callback (timers phase)");
}, 0);

process.nextTick(() => {
  console.log("C: process.nextTick callback (microtask queue)");
});

Promise.resolve().then(() => {
  console.log("D: promise callback (microtask queue)");
});

console.log("B: synchronous end");

Output:

A: synchronous start
B: synchronous end
C: process.nextTick callback (microtask queue)
D: promise callback (microtask queue)
E: setTimeout callback (timers phase)

Even with a 0ms delay, setTimeout never beats nextTick or a resolved promise — the timers phase only runs after both queues are fully drained.

Example 3: a blocking loop delays everything

This is the example that matters most in production: a synchronous, CPU-bound loop on the main thread delays a timer that should have fired much sooner.

console.log("Start");

const start = Date.now();

setTimeout(() => {
  console.log(`Timer fired after ${Date.now() - start}ms (requested 100ms delay)`);
}, 100);

function blockFor(ms) {
  const end = Date.now() + ms;
  while (Date.now() < end) {
    // busy-wait: nothing else, including the timer above, can run during this loop
  }
}

blockFor(500);
console.log("Finished blocking work");

Output:

Start
Finished blocking work
Timer fired after ~500ms (requested 100ms delay)

The timer was ready at 100ms, but the event loop was stuck inside blockFor‘s synchronous while loop and could not check the timers phase until that loop finished around 500ms. In a real server, every connected client would have been frozen for that same 500ms — this is exactly what “blocking the event loop” means.

Under the Hood: Step by Step

To see the poll-to-check ordering that libuv guarantees (as opposed to the top-level setTimeout vs setImmediate race, which is not guaranteed), schedule both from inside an I/O callback:

const fs = require("node:fs");

console.log("Reading file...");

fs.readFile(__filename, "utf8", (err, data) => {
  if (err) throw err;

  setTimeout(() => console.log("timers phase: setTimeout"), 0);
  setImmediate(() => console.log("check phase: setImmediate"));

  console.log("I/O callback finished; phases below run on the next loop iteration");
});

Output:

Reading file...
I/O callback finished; phases below run on the next loop iteration
check phase: setImmediate
timers phase: setTimeout

Step by step: (1) fs.readFile is dispatched to the thread pool while the script continues; (2) “Reading file…” prints synchronously; (3) the script has no more synchronous work, so the loop enters the poll phase and waits; (4) the file finishes reading, and the poll phase runs its callback, which synchronously logs the third line and schedules both a timer and an immediate; (5) the poll phase has nothing else pending, so libuv moves straight into the check phase and runs the setImmediate callback; (6) only on the loop’s next full pass does it reach the timers phase and run the setTimeout callback. This poll-then-check-then-timers order inside an I/O callback is guaranteed by design; at the very top of a script, before any I/O has happened, the relative order of a 0ms setTimeout and a setImmediate is not guaranteed and depends on process startup timing.

Common Mistakes

Mistake 1: using a synchronous call inside a request handler

The *Sync functions block the entire process, not just the current request. Using one inside an HTTP handler stalls every other connected client.

const http = require("node:http");
const fs = require("node:fs");

const server = http.createServer((req, res) => {
  const data = fs.readFileSync("./big-file.txt", "utf8");
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end(data);
});

server.listen(3000);

Fix it with the promise-based fs/promises API so the read happens off the main thread’s critical path:

const http = require("node:http");
const fs = require("node:fs/promises");

const server = http.createServer(async (req, res) => {
  try {
    const data = await fs.readFile("./big-file.txt", "utf8");
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end(data);
  } catch (err) {
    res.writeHead(500, { "Content-Type": "text/plain" });
    res.end("Internal Server Error");
  }
});

server.listen(3000);

Mistake 2: doing CPU-heavy work directly on the main thread

It isn’t just filesystem calls — any synchronous, CPU-bound computation blocks the loop the same way, even ones that have nothing to do with I/O, such as a large synchronous password-hashing call.

const http = require("node:http");
const crypto = require("node:crypto");

const server = http.createServer((req, res) => {
  const hash = crypto.pbkdf2Sync("password", "salt", 5_000_000, 64, "sha512");
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end(hash.toString("hex"));
});

server.listen(3000);

The async version of the same function offloads the work to libuv’s thread pool, keeping the main thread free to handle other requests while it runs:

const http = require("node:http");
const crypto = require("node:crypto");

const server = http.createServer((req, res) => {
  crypto.pbkdf2("password", "salt", 5_000_000, 64, "sha512", (err, hash) => {
    if (err) {
      res.writeHead(500, { "Content-Type": "text/plain" });
      res.end("Internal Server Error");
      return;
    }
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end(hash.toString("hex"));
  });
});

server.listen(3000);

For work that is too heavy even for the thread pool’s async APIs (custom number crunching, image processing), reach for worker_threads to run it on a genuinely separate JavaScript thread instead.

Best Practices

  • Never call a *Sync function (fs.readFileSync, crypto.pbkdf2Sync, etc.) inside a request handler; reserve sync APIs for one-off startup or CLI scripts where nothing else is waiting on the thread.
  • Offload genuinely CPU-heavy work (image processing, large computations) to worker_threads or a separate process — don’t run it on the main thread even inside an async function, since await does not make CPU-bound code non-blocking.
  • Prefer the promise-based core APIs (fs/promises) and async/await over raw error-first callbacks for new code.
  • Always attach an error listener to servers, sockets, and streams — an unhandled error event throws and can crash the whole process.
  • Don’t rely on the relative order of a top-level 0ms setTimeout versus setImmediate; only the poll-then-check ordering inside an I/O callback is guaranteed.
  • When a server feels sluggish under load, suspect a blocking call before scaling hardware — tools like --prof or Clinic.js can confirm event loop delay directly.

Practice Exercises

  • Write a script that logs “one” synchronously, then schedules “two” with setTimeout(fn, 0), “three” with process.nextTick(), and “four” with Promise.resolve().then(). Predict the print order before running it, then check.
  • Build a small HTTP server with two routes: /fast responds immediately, and /slow runs a synchronous loop that counts to a few hundred million. Request /slow and then immediately request /fast from another tab — observe that /fast is delayed until /slow‘s loop finishes.
  • Rewrite the /slow route from the previous exercise using an async API (or a worker_threads worker) so that /fast is no longer delayed. Add timing logs to confirm the fix.

Summary

  • Node.js embeds V8 to execute JavaScript and pairs it with libuv, which supplies the event loop, the thread pool, and async I/O bindings to the OS.
  • Your JavaScript always runs on a single main thread — true parallelism only happens in libuv’s background thread pool or in an explicit worker_threads worker.
  • The event loop cycles through fixed phases — timers, pending callbacks, poll, check, close callbacks — and fully drains the process.nextTick queue and then the Promise microtask queue between every callback.
  • Non-blocking I/O is what lets one thread serve many clients at once, but any synchronous, CPU-heavy code on that thread blocks every single one of them.
  • Favor async and promise-based APIs, avoid *Sync calls in request paths, and move genuinely heavy computation off the main thread entirely.