Node.js Internals — The Complete Guide
Chapter 1
First, forget Node.js.
Imagine a restaurant.
Before we talk about threads, event loops, or libuv — let's talk about a waiter.
It's Friday night. The restaurant has 6 tables, all full. There's only one waiter. A bad waiter would take Table 1's order, walk to the kitchen, stand there for 20 minutes waiting for the food, bring it back, then go to Table 2. By the time Table 6 orders, it's midnight. Everyone hates the restaurant.
But a smartwaiter does something different. He takes Table 1's order, hands it to the kitchen, and immediately goes to Table 2. Then Table 3. Then Table 4. The kitchen is doing the actual cooking — the waiter is just shuttling information. When the kitchen rings the bell, the waiter picks up the plate and delivers it. One waiter. Six happy tables. No waiting.
The waiter = the Event Loop (single thread). The kitchen = libuv + OS + Thread Pool. "Cooking" = file reads, database queries, network calls. The waiter never cooks. He just coordinates.
Let's simulate this right now. Click "New Customer" to add tables and watch the waiter handle all of them without freezing.
Watch the "waiter" (event loop) handle all tables simultaneously. He takes the order instantly and moves on — the kitchen handles the waiting.
See that? The waiter isn't waiting at any table. He fires off each order and loops around. The kitchen is handling the "slow parts." This is exactly how Node.js works.
Chapter 2
"Single threaded" doesn't mean what you think
Someone tells you: "Node.js is single threaded, so it can only do one thing at a time." You panic. "But my server has to handle 10,000 users!" Should you be worried?
The confusion comes from not knowing which partis single threaded. Let's break Node.js into its actual layers:
This is the single thread. Your callbacks, your route handlers, your business logic — all run here, one at a time. This is what "single threaded" means.
This sits between your JS and the OS. It manages the event loop, a thread pool of 4 workers, and interfaces with the OS for async I/O.
File reads, crypto, DNS lookups, zlib — these go to real OS threads that run in TRUE parallel. Your JS thread never sees this work.
Network calls, TCP sockets — the OS itself handles these with epoll (Linux) or kqueue (Mac). Node doesn't use thread pool threads for these. The OS manages thousands simultaneously for free.
Your JavaScript is single threaded. Everything else — file reading, network calls, cryptography — runs in parallel underneath. The single thread is only busy during the microseconds it takes to hand off work and receive results.
The Event Loop's phases — click each one
The event loop isn't just "a loop." It has 6 distinct phases it cycles through continuously. Click each phase to understand what it does:
Timers phase
Runs callbacks scheduled by setTimeout() and setInterval(). Note: a timer of "0ms" doesn't mean immediately — it means "run as soon as the loop reaches this phase and the time has passed."
Chapter 3
What actually happens with 100,000 simultaneous requests?
Your app suddenly goes viral. 100,000 people hit your server at the exact same moment. All of them want data from your database. You're running one Node.js process. Does your server explode?
Let's trace exactly what happens, step by step:
Hit the button below to watch this happen in slow motion:
The event loop callback for each request runs in about 0.1–1ms. Even with 100,000 requests queued, the last one only waits ~10 seconds total. Meanwhile, all the actual DB/network work is happening in parallel. You're not limited by the single thread — you're limited by your database's throughput.
Chapter 4
What happens when you read a large file?
A user uploads a 500MB log file to your server. You need to read it and process it. You write fs.readFile('bigfile.log', callback). Where does that file go? Is your server frozen while it reads? What's actually happening?
Here's the complete journey of a file read request:
Notice: the thread that reads the file is blocked for the entire 800ms. But that thread is not your main JavaScript thread. It's one of libuv's 4 worker threads. Your event loop keeps spinning freely the whole time.
When the file finishes reading, libuv loads ALL 500MB into RAM at once before calling your callback. If 100 users upload 500MB files at the same time, that's 50GB of RAM instantly. Your server dies not from CPU, but from memory. This is why streaming exists.
fs.readFile vs fs.createReadStream
fs.readFile('big.zip', (err, data) => {
// data = entire 500MB Buffer
// NOW you can process it
});Problem: 500MB file = 500MB in RAM. 100 concurrent requests = 50GB. Server dies.
const stream = fs.createReadStream('big.zip');
stream.on('data', chunk => {
// chunk = 64KB at a time
// process, then ask for next
});100 concurrent? Still ~64KB per active chunk. RAM stays flat. Server lives.
fs.readFile. 50 users do this at once. How much RAM does your server need minimum?Chapter 5
What if all 4 thread pool threads are busy?
You have 4 threads in the pool. 4 requests are already reading huge files. A 5th request comes in and also needs to read a file. What happens? Does it crash? Does it wait forever? Does the event loop freeze?
Let's see the thread pool live. The 4 boxes below are your libuv threads. Hit the buttons to queue work and watch what happens when they're all occupied:
When all 4 threads are busy, new tasks queue up in libuv's work queue. The event loop is NEVER blocked — it keeps serving HTTP requests, running timers, processing other callbacks. Only the thread-pool-dependent operations (file I/O, crypto, DNS, zlib) get delayed. Network I/O is handled by the OS, not the thread pool, so it's completely unaffected.
What uses the thread pool vs what uses the OS directly?
crypto (bcrypt, pbkdf2, scrypt)
dns.lookup
zlib compression
custom C++ native modules
net.connect (TCP sockets)
https.request
setTimeout / setInterval
WebSockets
This distinction matters enormously. Your Node HTTP server can handle 10,000 simultaneous outbound API calls without touching the thread pool at all. The OS handles those. Only file I/O and crypto compete for your 4 threads.
Fix thread pool exhaustion: Set the environment variable UV_THREADPOOL_SIZE=16 (max 1024) before starting Node. Or better: use streaming for files (avoids blocking threads), and offload CPU-heavy crypto to Worker Threads.
Chapter 6
The real enemy: blocking the event loop
Your event loop is happily serving 5,000 requests per second. Then one request triggers this code: for(let i = 0; i < 100_000_000; i++) {}. What happens to all 5,000 other users mid-flight?
They all freeze. Every single one. No request gets a response until that loop finishes. This is Node's actual Achilles heel — not the thread pool, not the single thread doing async — but synchronous code that runs too long.
Let's prove it. Hit "Block the loop" and watch:
Common ways developers accidentally block the event loop
const data = JSON.parse(req.body); // 10MB JSON = ~200ms of blocking
const file = fs.readFileSync('big.log'); // blocks for the entire read durationapp.get('/calc', (req, res) => {
let sum = 0;
for (let i = 0; i < 1e9; i++) sum += i; // blocks for seconds
res.json({ sum });
});const hash = crypto.pbkdf2Sync(pw, salt, 100000, 64, 'sha512'); // BLOCKS
The fix: Worker Threads for CPU-heavy work
If you genuinely need heavy computation, use a Worker Thread. It runs in a real parallel OS thread — completely separate from your event loop:
// main.js
const { Worker } = require('worker_threads');
app.get('/heavy', (req, res) => {
const worker = new Worker('./heavy-compute.js', {
workerData: { input: req.query.data }
});
worker.on('message', result => res.json(result));
// Event loop is FREE while worker computes
});
// heavy-compute.js
const { workerData, parentPort } = require('worker_threads');
// This runs in a SEPARATE thread — blocking here is fine
const result = doHeavyComputation(workerData.input);
parentPort.postMessage(result);Chapter 7
Handling ZIP files the right way
Users upload ZIP files to your Node.js server. The files are 50MB–2GB. You need to unzip them, validate the contents, and store the files. What's the naive way? What's the production way? And why does it matter?
There are four approaches, from worst to best:
const buf = fs.readFileSync('upload.zip'); // BLOCKS event loop
const files = unzipSync(buf); // BLOCKS event loop again
// All other users frozen during both operationsfs.readFile('upload.zip', (err, buf) => {
// readFile is async — good!
// But 'buf' is still 2GB in RAM — bad!
// And unzip processing blocks the loop
const files = unzipAllAtOnce(buf); // blocks!
});const unzipper = require('unzipper');
fs.createReadStream('upload.zip')
.pipe(unzipper.Parse())
.on('entry', entry => {
// entry is a stream — process 64KB at a time
entry.pipe(fs.createWriteStream(`./out/${entry.path}`));
});
// RAM stays flat. Event loop stays free.// Streaming handles I/O (no RAM spike, no thread pool blocking)
// Worker Thread handles CPU validation (no event loop blocking)
const worker = new Worker('./zip-validator.js');
const stream = fs.createReadStream('upload.zip');
stream.pipe(unzipper.Parse()).on('entry', entry => {
worker.postMessage({ path: entry.path, size: entry.vars.uncompressedSize });
entry.pipe(fs.createWriteStream(`./out/${entry.path}`));
});
// Perfect. Nothing blocks. RAM stays low. All users happy.Why streaming is the universal answer
Streaming means: process data in small chunks (usually 64KB) as it arrives, instead of waiting for all of it. The benefits stack up:
Chapter 8
The complete cheat sheet — no doubts left
Everything we covered, distilled into clear rules. If you remember nothing else, remember these:
The mental model that ties it all together
Node.js is a very fast coordinator, not a very fast executor. It delegates all slow work to libuv, the OS, and thread pools. Your job as a Node developer is to keep the coordinator free. Never give it slow work to do itself. The moment your event loop callback takes more than a few milliseconds, you're failing all your other users simultaneously.