Part 2 of the series. If you haven't read Part 1, go read why Node.js exists, the browser sandbox, modules, and npm/npx first — this part assumes you know a Node app is a single JavaScript process talking to the outside world.
Saturday, Round 2
👦 Nephew: Uncle, I thought about it all week. Everyone keeps saying "Node.js is single-threaded but handles thousands of requests." That sentence doesn't fully make sense to me. Single-threaded should mean slow, one thing at a time. How is it fast then?
👨🦳 Uncle: smiles — that one sentence has confused more developers than any other in the entire ecosystem. Let's kill the confusion permanently today. Get comfortable, we're going into the engine room.
Part 2.1 — Single-Threaded, But Not Actually Alone
👨🦳 Uncle: Here's the trick nobody tells you clearly: your JavaScript code runs on a single thread. But Node.js itself — the C++ layer underneath — is not single-threaded at all.
Your JavaScript
|
ONE thread ← this part is single-threaded
|
Node.js (C++) + libuv
|
MULTIPLE threads working behind the scenes
👦 Nephew: Wait — so there's a whole C++ layer I never think about?
👨🦳 Uncle: Correct. That layer is called libuv. It's a C library that gives Node.js its event loop, its thread pool, and its ability to talk to the operating system for file systems, networking, timers, and more.
👦 Nephew: So when people say "Node is single-threaded," they only mean...
👨🦳 Uncle: ...that your JS callbacks execute one at a time, never in parallel with each other. That's it. Everything else — reading files, hashing passwords, DNS lookups — can happen in parallel, just not in JavaScript-land.
Part 2.2 — Meet libuv, The Actual Engine Room
👨🦳 Uncle: Think of your Node.js process like a restaurant.
Restaurant
+----------------------------------+
| |
| Waiter (Event Loop) |
| — takes orders, one at a time |
| — never cooks |
| — never waits at the stove |
| |
| Kitchen (libuv thread pool) |
| — multiple cooks |
| — cook in parallel |
| — hand finished dish back |
| |
+----------------------------------+
👦 Nephew: So the "waiter" is the event loop and never actually cooks anything?
👨🦳 Uncle: Exactly. It just takes your order (a function call), hands it to the kitchen if it's a slow job, and moves on to take the next customer's order immediately — instead of standing at the stove waiting.
👦 Nephew: Okay, but not everything goes to the kitchen, right? Some stuff the waiter does himself.
👨🦳 Uncle: Correct — this is the key split.
| Work Type | Who handles it | Examples |
|---|---|---|
| Fast, synchronous JS | Event loop itself | Math, loops, JSON.parse, string ops |
| Networking I/O | OS kernel (via epoll/kqueue/IOCP) | HTTP requests, TCP sockets |
| File system, DNS, crypto, zlib | libuv's thread pool |
fs.readFile, crypto.pbkdf2, dns.lookup
|
👦 Nephew: Wait — networking I/O doesn't even go to libuv's thread pool?
👨🦳 Uncle: Correct, and this surprises everyone. Network sockets are handled by the operating system's own async mechanism (epoll on Linux, kqueue on macOS, IOCP on Windows). libuv just asks the OS "tell me when this socket has data," and moves on. The thread pool (default size: 4 threads) is reserved mainly for file system operations, DNS, and certain crypto/zlib calls — things the OS doesn't offer a native async API for.
Network I/O → handled by OS kernel directly (epoll/kqueue/IOCP)
File I/O → handled by libuv's thread pool
DNS lookup → handled by libuv's thread pool
crypto.pbkdf2 → handled by libuv's thread pool
👦 Nephew: So my mental model of "libuv thread pool does everything async" was actually wrong?
👨🦳 Uncle: Slightly wrong, yes — and that's exactly the kind of detail interviewers love to poke at, because 90% of developers say "libuv handles all async I/O with threads," which is only half true.
Part 2.3 — The Event Loop, Phase by Phase
👦 Nephew: Okay now walk me through the actual event loop. I keep hearing "phases" but never really saw them laid out.
👨🦳 Uncle: Here's the loop, simplified into its real phases:
┌───────────────────────────┐
┌─>│ timers │ setTimeout, setInterval callbacks
│ ├───────────────────────────┤
│ │ pending callbacks │ some system-level callbacks
│ ├───────────────────────────┤
│ │ idle, prepare │ internal use only
│ ├───────────────────────────┤
│ │ poll │ fetch new I/O events, run their callbacks
│ ├───────────────────────────┤
│ │ check │ setImmediate callbacks
│ ├───────────────────────────┤
│ │ close callbacks │ socket.on('close', ...)
│ └───────────────────────────┘
└──────────── loop again ───────────
👦 Nephew: That "poll" phase sounds like the busiest one.
👨🦳 Uncle: It is — that's where most of your I/O callbacks actually fire: incoming HTTP data, finished file reads, database responses coming back from the driver. The event loop sits there, waits for events, and processes whichever ones are ready.
👦 Nephew: And where do Promises fit? I keep hearing "microtask queue" separately from all this.
👨🦳 Uncle: Good catch — Promises (.then(), async/await) and process.nextTick() don't wait for a full phase. They run in a microtask queue, which gets fully drained after every single callback, before the loop moves to the next phase.
Run one callback
|
Drain ALL microtasks (Promises, nextTick)
|
Move to next phase
👦 Nephew: So microtasks basically cut the queue every time?
👨🦳 Uncle: Every time. That's why this famous snippet always logs in the same order:
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
Output:
1
4
3
2
👦 Nephew: Because the Promise's .then() is a microtask, and it jumps ahead of the setTimeout callback even though both were "scheduled for zero delay."
👨🦳 Uncle: Exactly — you just explained it better than half the blog posts online.
Part 2.4 — What "Async" Actually Means (No More Hand-Waving)
👦 Nephew: This is the part I really want you to slow down on. When I write await db.query(...), what is actually happening physically? Not the textbook answer — the real one.
👨🦳 Uncle: Let's trace it like a detective story.
1. Your JS calls db.query(sql)
2. The driver hands the request to the OS socket layer
3. Node's main thread does NOT wait — it registers a callback
and immediately returns to the event loop to do other work
4. The OS/network handles the actual back-and-forth to the DB server
5. When the DB server responds, the OS marks the socket "readable"
6. libuv/event loop notices this in the "poll" phase
7. Node runs your callback (or resolves your Promise)
👦 Nephew: So my JavaScript function was literally paused, but the thread was never blocked?
👨🦳 Uncle: Precisely. That's the entire idea behind non-blocking I/O. await doesn't mean "wait on this thread." It means "pause this function, let the thread go do other things, and come back to continue this exact function the moment the answer shows up."
👦 Nephew: Then why do people say "Node can't do CPU-heavy work well"?
👨🦳 Uncle: Because CPU-heavy work (image resizing, huge loops, encryption of large payloads) has nowhere to hand off to. There's no OS-level "notify me when this math is done" — the CPU work itself has to occupy a thread, and if that thread is your one JS thread, everything else waiting behind it in the queue simply freezes.
I/O-bound work → "go ask someone else, tell me when it's done" → Node is great here
CPU-bound work → "I must personally sit and compute this" → Node struggles here
👦 Nephew: So that's exactly why Worker Threads exist?
👨🦳 Uncle: You read my mind. Let's go there.
Part 2.5 — Worker Threads (When libuv's Thread Pool Isn't Enough)
👨🦳 Uncle: libuv's thread pool is great for file/DNS/crypto work, but it's not meant for your heavy JavaScript computation — like resizing a 10MB image in pure JS, or crunching a giant array. For that, Node gives you actual Worker Threads.
// app.js (main thread)
const { Worker } = require('worker_threads');
const worker = new Worker('./worker.js');
worker.postMessage({ number: 40 });
worker.on('message', (result) => {
console.log('Result from worker:', result);
});
// worker.js (separate thread)
const { parentPort } = require('worker_threads');
parentPort.on('message', ({ number }) => {
const result = heavyFibonacci(number); // CPU-heavy work here
parentPort.postMessage(result);
});
👦 Nephew: So this actually spins up a full separate thread, with its own V8 instance?
👨🦳 Uncle: Yes — each Worker Thread gets its own V8 isolate and event loop. They don't share memory by default; they talk via message-passing (postMessage), like two people sending letters, not shouting across the same room.
👦 Nephew: When should I actually reach for this instead of just... doing it normally?
👨🦳 Uncle: Use this rule:
| Situation | Use |
|---|---|
| Reading a file, calling a DB, hitting an API | Normal async — libuv/OS handles it, no worker needed |
| Hashing a password once | Fine on main thread, or thread pool, it's brief |
| Resizing large images, heavy encryption, big data crunching, ML inference in JS | Worker Threads — protect your main thread |
| Truly parallel, isolated CPU workloads at scale | Worker Threads, or separate processes via cluster
|
Part 2.6 — Process, Cluster, and PM2 (The Bigger Picture)
👦 Nephew: Speaking of scale — I keep seeing "cluster mode" and "PM2" mentioned around production Node apps. How does that relate to all this?
👨🦳 Uncle: Zoom out one level. A single Node process uses one core of your CPU, no matter how many cores your server has.
8-core server
|
Node process → uses only 1 core
|
7 cores sit idle unless you do something about it
👨🦳 Uncle: cluster (Node's built-in module) or PM2 (a process manager) solve this by forking multiple Node processes — one per core — all listening on the same port, with the OS load-balancing incoming connections between them.
Incoming Requests
|
Load Balanced
/ | \
Process 1 Process 2 Process 3 (each its own event loop, own memory)
👦 Nephew: So each process is a totally separate world?
👨🦳 Uncle: Completely separate — separate memory, separate event loop, separate everything. That's why if you store something in a plain JS variable in one process, another process has no idea it exists. This is exactly why real production chat/session data lives in something shared like Redis, not in process memory — a topic for Part 3.
👦 Nephew: Restaurant analogy again?
👨🦳 Uncle: Sure — cluster mode is like opening five identical restaurant branches. Every branch has its own kitchen and its own waiter. A customer walking into Branch 3 has zero idea what's cooking in Branch 1.
Part 2.7 — Stack vs Heap (Where Your Variables Actually Live)
👦 Nephew: Switching gears completely — where does V8 actually keep the stuff I write? Like a variable let x = 5?
👨🦳 Uncle: Two places, and knowing the difference explains half of JS's weird behavior.
Stack Heap
+----------------+ +---------------------------+
| x = 5 | | { name: "Suraj", age: 27 }|
| y = "hi" | | [1, 2, 3, 4, 5] |
| ref -----------+------------>| function() {...} |
+----------------+ +---------------------------+
fixed-size, dynamic-size,
fast, LIFO order slower, garbage collected
👦 Nephew: So primitives (numbers, strings, booleans) go on the stack, and objects/arrays/functions go on the heap?
👨🦳 Uncle: Exactly. And when you have let obj = { name: 'Suraj' }, the variable reference (a pointer) sits on the stack, but the actual object data lives in the heap. That's why this behaves the way it does:
function updateName(person) {
person.name = "Changed";
}
const user = { name: "Suraj" };
updateName(user);
console.log(user.name); // "Changed"
👦 Nephew: Because person inside the function is a copy of the reference, but it points to the same heap object as user.
👨🦳 Uncle: Correct — objects are passed "by reference value." The pointer is copied, not the object.
👦 Nephew: And function calls themselves — where do those live?
👨🦳 Uncle: Each function call gets a stack frame, pushed on top when called, popped off when it returns.
Stack (grows downward as functions call each other)
main()
└── processOrder()
└── calculateTotal()
└── applyDiscount() ← currently executing, top of stack
👨🦳 Uncle: If this chain goes too deep without returning — recursion with no base case, for example — you get the classic:
RangeError: Maximum call stack size exceeded
👦 Nephew: Stack overflow, literally named after what it is.
👨🦳 Uncle: Exactly. Now — heap objects don't clean themselves up. Something has to. That something is...
Part 2.8 — Garbage Collection (V8's Cleanup Crew)
👦 Nephew: Garbage collection — I use JS daily and have genuinely never thought about how it decides what to delete.
👨🦳 Uncle: Simple core idea: if nothing can reach an object anymore, it's garbage.
let user = { name: "Suraj" };
user = null; // the object { name: "Suraj" } is now unreachable → garbage
Before:
stack: user ----> { name: "Suraj" } (reachable)
After user = null:
stack: user ----> null
{ name: "Suraj" } floats alone, unreachable → eligible for GC
👦 Nephew: How does V8 actually find unreachable objects? Does it scan every variable name?
👨🦳 Uncle: It uses an algorithm family called mark-and-sweep.
1. MARK — start from "roots" (global vars, currently running functions)
and walk every reachable reference, marking each object "alive"
2. SWEEP — anything NOT marked is garbage, memory is reclaimed
👦 Nephew: And this runs constantly?
👨🦳 Uncle: No — that would be wasteful. V8 uses generational garbage collection, based on a simple observed truth: most objects die young.
New Space (small, fast, frequent GC)
|
survives a few GC cycles?
|
↓ promoted
|
Old Space (larger, GC runs much less often)
👦 Nephew: So a short-lived variable inside a function that returns quickly never even reaches Old Space?
👨🦳 Uncle: Exactly — it's created, used, and swept up in the very next "minor GC" pass in New Space, which is fast and cheap. Only long-lived objects (caches, global state, things referenced for a while) graduate to Old Space, where GC is more thorough but rarer.
👦 Nephew: And memory leaks — how do those even happen if GC is automatic?
👨🦳 Uncle: A "leak" in JS almost always means: something is still referencing an object you think is unused, so GC correctly refuses to collect it. Classic culprits:
const cache = {};
function addToCache(key, data) {
cache[key] = data; // never removed — grows forever
}
Global cache object
|
keeps growing
|
never eligible for GC
|
memory usage climbs until the process crashes
👨🦳 Uncle: Other common culprits: forgotten setInterval timers holding references, event listeners never removed, and closures accidentally holding onto large objects longer than needed.
Part 2.9 — Buffers: The Odd One Out
Nephew: Now the part that broke my brain the first time — Buffers. Someone said "Buffers live outside the heap." That sounded like nonsense.
👨🦳 Uncle: It sounds like nonsense until you draw it. Let's fix that misconception properly, because most developers state it wrong even in interviews.
👦 Nephew: Wait — first, why does Node even need Buffers? Why not just use a normal JS array of numbers?
👨🦳 Uncle: Because JavaScript was born to handle text, not raw binary data. But Node constantly deals with binary — file bytes, image bytes, network packets, TCP chunks. A regular JS array is heavy and slow for this: every number in a JS array carries extra overhead. A Buffer is a lean, fixed-size chunk of raw bytes — closer to how C handles memory.
const buf = Buffer.alloc(10);
console.log(buf); // <Buffer 00 00 00 00 00 00 00 00 00 00>
👦 Nephew: Ok so where does this live, actually?
👨🦳 Uncle: Here's the accurate picture:
Stack
|
buf (just a reference/pointer)
|
v
V8 Heap
+------------------------+
| Buffer object |
| length = 10 |
| pointer ---------------+-----------------+
+------------------------+ |
v
Native Memory (outside V8 heap)
+--------------------------+
| 10 raw bytes |
+--------------------------+
👦 Nephew: So the Buffer object — the JS wrapper with .length, methods etc — lives in the normal V8 heap. But the actual raw bytes it's pointing to live in native memory, managed directly by Node's C++ layer, not V8?
👨🦳 Uncle: Exactly right, and that's the precise correction to remember:
❌ "A Buffer is allocated outside the heap" (imprecise)
✅ "The Buffer object lives in the V8 heap. The binary data it manages lives in native memory outside the V8 heap."
👦 Nephew: Why does that separation even matter practically?
👨🦳 Uncle: Two big reasons. First — V8's heap has size limits and its GC pauses would be brutal if huge binary blobs (like a 50MB video chunk) lived there. Second — native memory can be handed directly to OS-level operations (disk writes, network sends) without V8 needing to touch or copy it, which is faster.
👦 Nephew: And Buffer.alloc() vs Buffer.allocUnsafe() — I've used both without knowing why two exist.
👨🦳 Uncle: Buffer.alloc(10) zero-fills the memory before giving it to you — safe, slightly slower.
Buffer.alloc(10)
|
Allocate 10 bytes
|
Fill all with 0x00
|
Return to you
Buffer.allocUnsafe(10) skips the zero-fill step — faster, but the memory might contain leftover data from something else that used it before — old tokens, old passwords, anything.
Buffer.allocUnsafe(10)
|
Allocate 10 bytes
|
Return IMMEDIATELY — no cleaning
👦 Nephew: So if I use allocUnsafe and don't overwrite it properly, I could accidentally leak old memory contents into a response?
👨🦳 Uncle: Precisely why the rule is: only use allocUnsafe when you are about to fully overwrite every byte yourself, like buf.fill(0) or writing a known-length payload into it immediately.
👦 Nephew: One more — for lots of tiny Buffers, does Node ask the OS for memory every single time?
👨🦳 Uncle: No, that would be expensive. Node keeps an internal pool (an 8KB slab, by default) for small allocations, slicing pieces off it as needed.
8 KB Pool
+--------------------------------------+
| [100b][200b][300b][ ... free space ] |
+--------------------------------------+
👨🦳 Uncle: Once the pool runs out, Node grabs a fresh pool from the OS. Large Buffers (think, above a few KB — practically anything like tens of MB) skip the pool entirely and get their own dedicated native allocation.
Part 2.10 — Streams: Why We Don't Just Load Everything At Once
👦 Nephew: Last one for today — Buffers naturally led me to Streams. When Node receives a big file upload, does it wait for the whole file before doing anything?
👨🦳 Uncle: No — and this is one of Node's most elegant ideas. Data arrives in small chunks, each one a small Buffer, and Node lets you process each chunk as it arrives instead of waiting for everything.
Big file upload
|
Chunk 1 (Buffer) → processed immediately
Chunk 2 (Buffer) → processed immediately
Chunk 3 (Buffer) → processed immediately
|
...
|
Chunk N (Buffer) → processed immediately
👨🦳 Uncle: Think of a Buffer as one bucket of water, and a Stream as the entire pipeline of buckets flowing past you, one at a time, instead of one giant tanker truck arriving all at once.
👦 Nephew: Why not just wait and load the whole thing? Simpler code, right?
👨🦳 Uncle: Simpler code, terrible memory usage. Imagine a 2GB video upload — buffering the entire thing in memory before doing anything means your server's RAM usage spikes by 2GB per upload. Do that with 50 concurrent uploads and your server is dead.
// Bad for large files — loads everything into memory first
fs.readFile('bigVideo.mp4', (err, data) => {
uploadToS3(data);
});
// Good — streams chunk by chunk, low constant memory usage
fs.createReadStream('bigVideo.mp4').pipe(uploadStream);
👦 Nephew: So Streams are basically Node's answer to "how do I handle huge data without exploding memory"?
👨🦳 Uncle: Exactly. And it pairs beautifully with what we covered — Buffers are the unit, Streams are the delivery mechanism, and the event loop is what lets Node process each chunk the instant it's ready, without blocking on the rest.
👦 Nephew: Uncle, my brain is genuinely full. Stack, heap, GC, Buffers, Streams, libuv, worker threads — all in one sitting.
👨🦳 Uncle: Good. That means it's sinking in properly instead of being memorized for an interview and forgotten a week later. Next time — we put ALL of this together into one single story: what actually happens, physically, from the second you click a button in your browser to the second data comes back — including what happens when a traffic burst hits, why rate limiters exist, and how WebSockets manage to keep 100,000 people connected at once without melting your server.
👦 Nephew: That sounds like the final boss.
👨🦳 Uncle: It is. Rest today. Bring your laptop next time, we're tracing a real request end to end.
What we covered in Part 2
- Why "single-threaded" only refers to your JS callbacks, not all of Node
- libuv, the event loop's phases, and where microtasks fit
- What "async" physically means — no thread blocking, just handoff-and-resume
- I/O-bound vs CPU-bound, and why Worker Threads exist
- Cluster/PM2 and multi-process scaling
- Stack vs Heap, and how objects are passed by reference
- Garbage collection — mark-and-sweep, generational GC, and memory leaks
- Buffers — the real truth about where they live in memory
-
Buffer.alloc()vsBuffer.allocUnsafe(), and the internal buffer pool - Streams — why chunked processing beats loading everything at once
Next up — Part 3: "The Complete Journey" — the full request lifecycle from browser click to database and back, traffic bursts and backpressure, rate limiting under attack, and how WebSockets + Redis let Node.js handle massive real-time systems.

Top comments (0)