Node.js NotesRohit’s interview study guide
Chapter 01

Node.js Fundamentals

What Node is made of, what "single-threaded" really means, and how it compares to thread-per-request runtimes.

7 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What Node.js is: V8, libuv and the bindings

Added

Node.js is not a language and not a framework. It is a runtime: a C++ program that embeds a JavaScript engine and gives it access to the outside world. On its own, JavaScript can add numbers, build objects and call functions, but it has no idea what a file, a socket or a clock is. Node supplies those.

It is built from four layers:

  • V8 — Google's JavaScript engine (the one in Chrome). It parses and compiles your code, runs it on the call stack, owns the heap and does garbage collection. V8 knows nothing about files, networks or timers.
  • libuv — a C library that provides the event loop, cross-platform asynchronous I/O (epoll on Linux, kqueue on macOS, IOCP on Windows), timers, and a small thread pool for work the OS can't do asynchronously.
  • The C++ bindings — Node's own glue code. They expose libuv, OpenSSL (crypto), zlib (compression), llhttp (HTTP parsing) and c-ares (DNS) to JavaScript as native functions.
  • Node's JavaScript library — the node:fs, node:http, node:events… modules you require. They are written in JavaScript and call down into the bindings.

A mental model: V8 is a chef who can only cook; libuv is the kitchen manager with a pager, a phone and a few helpers. The chef never waits by the oven or at the door. They hand the waiting to the manager, keep cooking, and the manager hands them a ticket when something is ready. The bindings are the intercom between them.

You can see the parts and their versions in any Node:

versions.cjs
const v = process.versions;
console.log({ node: v.node, v8: v.v8, uv: v.uv, openssl: v.openssl, zlib: v.zlib, llhttp: v.llhttp, ares: v.ares });
// {
//   node: '26.7.0',
//   v8: '14.6.202.34-node.28',
//   uv: '1.52.1',
//   openssl: '3.6.3',
//   zlib: '1.2.12',
//   llhttp: '9.4.3',
//   ares: '1.34.8'
// }
AdvancedSeeing the layers: the stack inside a callback

Each line is a separate project that Node bundles: the engine (v8), the event loop (uv is libuv), and the native libraries the bindings wrap. When Node's release notes say "libuv updated to 1.45", that is where event-loop behaviour can change — chapter 2's phases topic has a real example.

The clearest way to see the layers working together is to look at the stack inside a callback:

stack.cjs
const fs = require('node:fs');

fs.readFile(__filename, () => {
  console.log(new Error('inside the readFile callback').stack);
});
setTimeout(() => console.log(new Error('inside a timer').stack), 5);
setImmediate(() => console.log(new Error('inside setImmediate').stack));

// Error: inside setImmediate
//     at Immediate._onImmediate (…/stack.cjs:7:32)
//     at process.processImmediate (node:internal/timers:574:21)
// Error: inside the readFile callback
//     at …/stack.cjs:4:15
//     at FSReqCallback.readFileAfterClose [as oncomplete] (node:internal/fs/read/context:96:3)
// Error: inside a timer
//     at Timeout._onTimeout (…/stack.cjs:6:30)
//     at listOnTimeout (node:internal/timers:685:17)
//     at process.processTimers (node:internal/timers:618:7)
What’s happening
  1. The script runs top to bottom on the main thread. fs.readFile (Node's JS library) creates a native request object, FSReqCallback, through the bindings and hands the actual file reading to libuv. setTimeout and setImmediate just register callbacks. All three calls return immediately and the script ends.
  2. Notice what is missing from every trace: there is no stack.cjs top-level frame and no readFile frame. By the time each callback runs, the script that scheduled it has long finished and its stack frames are gone.
  3. The bottom frame of each trace is where the loop came back into JavaScript. For the file read, libuv finished the work, the C++ binding called the request's oncomplete method (readFileAfterClose), and that called your function.
  4. For the timer and the immediate, the loop called Node's internal processTimers and processImmediate functions, which walk Node's own lists and call your callbacks one by one.
  5. This is the whole architecture in one picture: your JavaScript only ever runs because the loop (C/C++) calls into V8, either to run the main script or to deliver a callback. The order of these three is not a coincidence — the setTimeout vs setImmediate topic explains it.
AdvancedWhat runs on the main thread vs elsewhere

What runs on the main thread: your code, Node's JavaScript library, every callback, every promise reaction, and the libuv loop itself (including the "wait for I/O" system call). What runs elsewhere: libuv's thread pool (file system, crypto.pbkdf2, zlib, dns.lookup), V8's helper threads (parts of garbage collection and compilation), and the operating system kernel, which does the actual network I/O. Chapter 2 starts with exactly which work goes where.

#What "single-threaded" really means

When people say "Node is single-threaded", they mean something narrower than it sounds: your JavaScript runs on one main thread, one piece at a time. There is one call stack, one heap for your objects, and no two of your callbacks ever run simultaneously. That is why you never need locks around a shared variable in normal Node code.

It does not mean the Node process has one thread. The process has several: V8 runs helper threads, and libuv starts a thread pool the first time something needs it. Those threads never run your JavaScript; they do native work and report back to the main thread through the event loop.

threads.cjs
const { execFileSync } = require('node:child_process');
const crypto = require('node:crypto');

// macOS: `ps -M <pid>` prints one line per thread (plus a header line).
const count = () => execFileSync('ps', ['-M', String(process.pid)]).toString().trim().split('\n').length - 1;

console.log('threads at startup:', count());
crypto.pbkdf2('x', 'y', 1, 32, 'sha256', () => {
  console.log('threads after first thread-pool job:', count());
});

// threads at startup: 7
// threads after first thread-pool job: 11
What’s happening
  1. count() asks the OS how many threads the current process has. At startup there are already 7: the main thread plus helper threads started by V8 and Node (garbage collection, compilation, platform tasks). Your JavaScript runs on exactly one of them.
  2. crypto.pbkdf2 is a thread-pool operation. libuv creates its pool lazily, on first use, with 4 threads by default (UV_THREADPOOL_SIZE changes that).
  3. When the hash completes, its callback runs on the main thread and counts again: 11, which is 7 + the 4 new pool threads.
  4. So "single-threaded" is about where JavaScript executes, not about the process. On Linux you'd count threads with ls /proc/<pid>/task; the numbers can differ by platform and version, but the +4 jump is the pool.
AdvancedRun-to-completion: a blocked timer

The practical consequence of one JavaScript thread is run-to-completion: once a callback starts, nothing else in your program runs until it returns. A timer that is due cannot interrupt a busy loop:

blocked-timer.cjs
const t0 = Date.now();
setTimeout(() => console.log(`timer due at 0 ms ran at ${Date.now() - t0} ms`), 0);

const end = Date.now() + 100;
while (Date.now() < end) {} // 100 ms of synchronous work
console.log(`loop finished at ${Date.now() - t0} ms`);

// loop finished at 100 ms
// timer due at 0 ms ran at 101 ms
What’s happening
  1. setTimeout(…, 0) registers a timer that becomes eligible to run after about 1 ms (Node turns 0 into 1).
  2. The while loop then occupies the main thread for 100 ms. The timer is due long before the loop ends, but the event loop can only check timers when the call stack is empty — and it isn't.
  3. The loop finishes at 100 ms, the script ends, the stack empties, and only then does the loop run the timer: 101 ms. A due timer is a callback waiting for the thread; it never pre-empts running code.
  4. In a server, "the timer" would be every other user's request. That is the core of Blocking the event loop.
AdvancedGetting more threads: workers and processes

There are two ways to get more JavaScript threads, and both keep the "one thread per JavaScript world" rule:

  • Worker threads (node:worker_threads) — each worker is a separate V8 isolate with its own heap and its own event loop, inside the same process. Workers don't share your variables; they exchange messages (or explicitly shared memory). See Worker threads.
  • More processes — child_process, the cluster module, or simply more containers.
AdvancedPitfall: logical races between awaits

#The REPL

From my notes: the REPL (Read-Eval-Print-Loop) is an interactive shell that ships with every Node installation. It reads a line of JavaScript, evaluates it, prints the result and loops back for more, until you exit. You start it by running node with no arguments.

It is the fastest way to answer "what does this actually return?" — the exact behaviour of Buffer.from, a regex, a date conversion — without creating a file. Here is a real session, typed into a terminal on Node 26:

node (REPL session)
Welcome to Node.js v26.7.0.
Type ".help" for more information.
> 1 + 2
3
> _ * 10
30
> await new Promise(r => setTimeout(() => r("done"), 50))
'done'
> null.x
Uncaught TypeError: Cannot read properties of null (reading 'x')
> _error.message
"Cannot read properties of null (reading 'x')"
> os.cpus().length
18
> .editor
// Entering editor mode (Ctrl+D to finish, Ctrl+C to cancel)
function twice(n) {
  return n * 2;
}
twice(21)

42
> .exit
What’s happening
  1. 1 + 2 is read, evaluated and its value printed: 3. Unlike a script, you never need console.log — the REPL prints the value of every expression (statements like const x = 1 print undefined).
  2. _ holds the last result, so _ * 10 is 30. Handy for building on a value you just computed.
  3. Top-level await works in the REPL, even though it's not an ES module: it waited 50 ms and printed 'done'.
  4. An exception doesn't kill the session. It prints Uncaught TypeError… and stores the error in _error, so you can inspect it afterwards.
  5. os works without require because the REPL exposes Node's core modules (os, fs, path, crypto…) as globals, loaded on first use. In a script you would need require('node:os').
  6. .editor switches to multi-line mode for pasting a whole function; Ctrl+D evaluates it all, here printing 42. .exit (or Ctrl+D at an empty prompt, or Ctrl+C twice) leaves. .help lists the other dot-commands: .break, .clear, .load, .save.
AdvancedHandy CLI flags: -p, -e and --watch

Related command-line tools that save time in practice:

  • node -p "expression" evaluates and prints one expression: node -p "[1,2,3].map(n => n * 2)" prints [ 2, 4, 6 ]. node -e "code" runs code without printing.
  • node --watch app.js re-runs the file every time it (or a file it imports) changes — a built-in replacement for nodemon in development. It prints Completed running 'app.js'. Waiting for file changes before restarting... when a script exits.
  • node --inspect opens the Chrome DevTools debugger; chapter 10 uses it for profiling.
AdvancedEmbedding a REPL in your own app

#I/O-bound vs CPU-bound work

Every piece of work is mostly waiting or mostly computing, and Node treats the two very differently.

  • I/O-bound work is dominated by waiting for something outside the process: a database query, an HTTP call, Redis, a file read, a message from a socket. The CPU is idle while you wait.
  • CPU-bound work is dominated by computation on your thread: parsing a huge JSON payload, resizing an image, hashing passwords synchronously, sorting a million rows, running a report.

The distinction matters because Node's model is built around giving the thread away during waits. While one request waits for the database, the same thread runs other requests' code. That only works if the waits are long and the computations are short. A CPU-bound task never gives the thread away, so everything else waits for it.

The difference shows up immediately when you try to "parallelise" both kinds with Promise.all:

io-vs-cpu.cjs
const { setTimeout: sleep } = require('node:timers/promises');

function busy(ms) {               // CPU-bound: keeps the thread computing
  const end = Date.now() + ms;
  while (Date.now() < end) {}
}

async function main() {
  let t = Date.now();
  await Promise.all([sleep(200), sleep(200), sleep(200)]); // three "DB calls"
  console.log(`3 x 200 ms of waiting:   ${Date.now() - t} ms`);

  t = Date.now();
  await Promise.all([1, 2, 3].map(async () => busy(200)));  // three "calculations"
  console.log(`3 x 200 ms of computing: ${Date.now() - t} ms`);
}
main();

// 3 x 200 ms of waiting:   201 ms
// 3 x 200 ms of computing: 600 ms
What’s happening
  1. sleep(200) (the promise version of setTimeout) stands in for a database call: it registers a timer and returns a pending promise immediately. All three timers are registered within a millisecond, so the three waits overlap.
  2. After ~200 ms all three timers fire, all three promises fulfil, and Promise.all resolves: 201 ms total, not 600. That overlap is what Node is good at.
  3. The second Promise.all looks the same, but .map(async () => busy(200)) calls each async function synchronously up to its first await — and there is no await inside, so each call runs the whole 200 ms loop before .map moves on to the next item.
  4. The three loops run one after another on the one thread: 600 ms. Wrapping CPU work in async or a promise does not move it anywhere; it still runs on the main thread.
  5. What decides the category is the work each request does, not how many requests arrive. A million requests that each wait on Redis are I/O-bound; one request that parses a 200 MB CSV is CPU-bound.
AdvancedHandling I/O-bound vs CPU-bound work

How to handle each kind:

  • I/O-bound — use the asynchronous APIs, run independent waits concurrently (Promise.all), and limit concurrency so you don't overwhelm the database (Limiting concurrency). One Node process can keep thousands of these in flight.
  • CPU-bound — keep it short on the main thread, split it into chunks (Yielding with setImmediate), or move it off the main thread: worker threads, a separate service, or a job queue. Chapter 11 covers the strategies.
AdvancedPitfall: CPU work hiding in everyday calls

#Concurrency vs parallelism

The two words are often used interchangeably, but interviewers expect you to separate them:

  • Concurrency is about structure: several tasks are in progress during overlapping periods of time. They take turns. One worker can be concurrent.
  • Parallelism is about execution: several computations are running at the same instant, on different CPU cores. You need more than one worker.

A coffee-shop analogy: one barista who starts the espresso, steams milk while it pours, and takes the next order during the steam is concurrent. Three baristas each making a drink at the same time is parallel. Node's main thread is the single barista — very good at juggling orders that involve waiting on machines.

My notes put it in capitals: "Node.js is good at concurrent I/O." Here is what that looks like — two requests in progress at once, on one thread:

interleave-req.cjs
const { setTimeout: sleep } = require('node:timers/promises');
const t0 = Date.now();
const log = (msg) => console.log(`${String(Date.now() - t0).padStart(3)} ms  ${msg}`);

async function handle(name, dbMs) {
  log(`${name}: start, query DB`);
  await sleep(dbMs);             // waiting: the thread is free
  log(`${name}: got rows, send response`);
}

handle('A', 100);
handle('B', 50);
log('both started');

//   0 ms  A: start, query DB
//   1 ms  B: start, query DB
//   1 ms  both started
//  52 ms  B: got rows, send response
// 102 ms  A: got rows, send response
What’s happening
  1. handle('A', 100) runs synchronously until its await: it logs A: start and starts a 100 ms "query". The await suspends A and returns a pending promise to the top level.
  2. handle('B', 50) does the same with a 50 ms query, then both started logs. Both requests are now in progress at the same time — that is concurrency — but neither is using the CPU. (The 1 ms jumps are the first console.log warming up, not the work.)
  3. At ~50 ms B's timer fires and B resumes: B: got rows. B finished first even though it started second, because its wait was shorter.
  4. At ~100 ms A resumes and finishes. Total: ~100 ms for 150 ms of combined waiting.
  5. At no point did two lines of JavaScript run at once. That is concurrency without parallelism: overlapping waits, one thread taking turns.
AdvancedParallelism with worker threads

For CPU work, concurrency doesn't help (you saw 600 ms above). You need parallelism, which in Node means more threads running JavaScript:

parallel.cjs
const { Worker } = require('node:worker_threads');

// Each worker is a separate JS thread (its own V8 isolate and event loop).
const code = `
  const end = Date.now() + 200;
  while (Date.now() < end) {}   // 200 ms of pure CPU
`;
const runInWorker = () =>
  new Promise((resolve, reject) => {
    new Worker(code, { eval: true }).once('exit', resolve).once('error', reject);
  });

const t = Date.now();
Promise.all([runInWorker(), runInWorker(), runInWorker()]).then(() => {
  console.log(`3 x 200 ms of computing in 3 workers: ${Date.now() - t} ms`);
});

// 3 x 200 ms of computing in 3 workers: 213 ms
What’s happening
  1. new Worker(code, { eval: true }) starts a new OS thread with a fresh V8 isolate and runs code there. The main thread doesn't wait; it gets a Worker object back immediately.
  2. Three workers start, so three busy loops run at the same time on three cores. The main thread is idle — it just waits for three exit events.
  3. Each exit resolves its promise; when the last one exits, Promise.all resolves: 213 ms. The ~13 ms over 200 is the cost of starting three isolates.
  4. Compare: the same work took 600 ms on the main thread. That is parallelism. It only helps if the machine has spare cores (os.availableParallelism() reports how many; 18 on this machine), and starting a worker per task is wasteful — real code uses a worker pool.

#Synchronous vs asynchronous APIs

Most of Node's I/O functions come in two flavours. fs.readFileSync is synchronous: it blocks the calling thread until the data is ready and returns it. fs.readFile is asynchronous: it starts the operation, returns undefined immediately, and calls your callback later with the result. (fs/promises has a third flavour, await readFile(...), which is the asynchronous version returning a promise.)

The example from my notes, extended to show the order things happen in:

sync-vs-async.cjs
const fs = require('node:fs');

console.log('1. before readFileSync');
const text = fs.readFileSync(__filename, 'utf8');      // thread waits here
console.log('2. readFileSync returned', text.length, 'chars');

fs.readFile(__filename, 'utf8', (err, data) => {        // thread moves on
  if (err) throw err;
  console.log('4. readFile callback', data.length, 'chars');
});
console.log('3. after readFile call');

// 1. before readFileSync
// 2. readFileSync returned 537 chars
// 3. after readFile call
// 4. readFile callback 537 chars
What’s happening
  1. readFileSync opens, reads and closes the file on the main thread, and only returns when it has every byte. Nothing else in the program can run during that time. Line 2 prints the 537 characters of this file (comments included).
  2. readFile asks libuv to do the same work on a thread-pool thread and returns immediately. The callback is just stored.
  3. So line 3 prints before line 4, even though it's later in the source. The main thread finished the script while a pool thread read the file.
  4. When the read completes, the loop calls the callback with err = null and the data: line 4. If the file were missing, err would be an ENOENT error — the error-first callback convention.
AdvancedMeasured: a sync read blocks a timer

The order is the easy part. The real cost of a sync call is that it stops everything else. Here a 512 MB file (already in the OS page cache) is read both ways while a 10 ms timer is pending:

block-timer.cjs
const fs = require('node:fs');
const path = process.argv[2];
const t0 = Date.now();
setTimeout(() => console.log(`timer due at 10 ms fired at ${Date.now() - t0} ms`), 10);

if (process.argv[3] === 'sync') {
  const buf = fs.readFileSync(path);
  console.log(`readFileSync: ${buf.length} bytes at ${Date.now() - t0} ms`);
} else {
  fs.readFile(path, (err, buf) => {
    if (err) throw err;
    console.log(`readFile:     ${buf.length} bytes at ${Date.now() - t0} ms`);
  });
}

// $ node block-timer.cjs big.bin sync
// readFileSync: 536870912 bytes at 47 ms
// timer due at 10 ms fired at 49 ms
//
// $ node block-timer.cjs big.bin async
// timer due at 10 ms fired at 10 ms
// readFile:     536870912 bytes at 62 ms
What’s happening
  1. Sync run. The timer is registered for 10 ms, then readFileSync holds the main thread for ~47 ms copying 512 MB. The timer becomes due at 10 ms but can't run until the read returns: it fires at 49 ms, almost 40 ms late.
  2. Async run. readFile hands the read to the thread pool and the script ends. The main thread is free, so the timer fires on time at 10 ms (9–10 ms across runs; Date.now() only has millisecond resolution) while a pool thread is still reading.
  3. The async read finishes at 62 ms — a bit slower than the sync one, because Node reads in chunks and goes through the loop. Async is not about making one operation faster; it is about keeping the thread free for everything else.
  4. In a server, the timer is every other request. A readFileSync in a route handler makes every concurrent user wait for that one file.
AdvancedCorrecting my notes: when sync code is fine

From my notes: "Avoid synchronous code — it blocks the event loop, preventing the application from handling other requests." That is right for anything on a request path. Sync APIs are fine, and often simpler, when nothing else needs the thread yet:

  • At startup, before the server listens: reading config, loading certificates, require() itself (which is synchronous).
  • In CLI tools and build scripts that do one thing at a time.
AdvancedPitfall: throwing from an async callback

#Node.js vs Java

The classic comparison is about how each one serves many requests at once.

A traditional Java server (Tomcat/Spring MVC, the servlet model) is thread-per-request: each request gets a thread from a pool, and that thread runs the handler from start to finish. When the handler calls the database, the thread blocks and sits there waiting. To serve 1,000 concurrent slow requests you need ~1,000 threads, and each platform thread costs memory for its stack and time for the OS to schedule. The upside: blocking code is simple to write and read, and CPU-heavy requests run in parallel on all cores for free.

Node is event-loop: one thread runs every request's JavaScript, and nothing blocks — a database call registers a callback (or an await) and the thread moves on to the next request. Thousands of concurrent waits cost a few objects each, not a thread each. The downside: one CPU-heavy request stalls everyone, and a single process uses one core for JavaScript.

Node.jsJava (classic servlet)
Concurrency modelOne event-loop thread, non-blocking I/OThread per request, blocking I/O
Cost of a waiting requestA callback/promise in memoryA whole thread parked
CPU-heavy workBlocks everyone; needs workers/processesRuns in parallel across cores
Using all coresCluster, more processes or containersOne process, many threads
Shared-state bugsNo data races; logical races across awaitData races; needs locks, synchronized, concurrent collections
Typical strengthsI/O-heavy APIs, real-time (WebSockets), BFFs, one language front and backCPU-heavy services, large codebases with strong static typing, mature JVM tooling

The modern twist is that the gap has narrowed from both sides:

  • Java has event loops too. Netty, Vert.x and Spring WebFlux are non-blocking, event-loop frameworks — the same model as Node, with the same "don't block the loop" rule.
  • Java 21's virtual threads (Project Loom) make thread-per-request cheap: a virtual thread that blocks on I/O is unmounted from its carrier OS thread, so you can have hundreds of thousands of them. You keep simple blocking code and get Node-like scalability for I/O.
  • Node can use all cores with worker threads for CPU work and the cluster module or multiple containers for throughput.

So the honest answer to "which is better?" is about fit: Node shines for I/O-heavy services, streaming and real-time connections, and teams already in TypeScript; Java (especially with virtual threads) shines for CPU-heavy or highly concurrent compute, very large domains that benefit from the JVM's tooling, and workloads that need true shared-memory multithreading.

Built from Rohit’s “Nodejs interview study” and “Nodejs-Interview-Chatgpt” docs. Examples are run on Node 26.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.