← Back to Blog
April 12, 202625 min read

Node.js Internals — The Complete Guide

Node.jsBackendEvent LoopSystem DesignPerformance

First, forget Node.js.
Imagine a restaurant.

Before we talk about threads, event loops, or libuv — let's talk about a waiter.

Picture this

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.

That waiter IS Node.js

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.

No customers yet. Click "New customer" above.
Waiter's action log
OrderingWaiting for kitchenEating (done)

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.

"Single threaded" doesn't mean what you think

Think about this

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:

Layer 1: Your JavaScript (V8 engine)

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.

Layer 2: libuv (C++ library)

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.

Layer 3: Thread pool (4 threads by default)

File reads, crypto, DNS lookups, zlib — these go to real OS threads that run in TRUE parallel. Your JS thread never sees this work.

Layer 4: OS kernel async I/O

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.

The real answer

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."

What actually happens with 100,000 simultaneous requests?

The big question

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:

1
The OS catches all 100,000 connections in its TCP buffer. Your Node.js process doesn't even see them yet. The kernel holds them all — this is the OS's job, not yours.
2
The event loop polls the OS and picks up whichever connections are ready. It runs your request handler callback for each one — very fast, one at a time, microseconds per callback.
3
For each request that needs a DB query, the event loop says to libuv "go do this query" and immediately moves on to the next request. It does NOT wait for the DB.
4
All 100,000 DB queries are now "in-flight" — happening simultaneously at the network/OS level. Node is free to handle the next batch of incoming connections.
5
As DB results come back, each callback fires on the event loop. The response is sent. Done. The event loop runs each response callback in ~0.1ms.

Hit the button below to watch this happen in slow motion:

0
Incoming requests
0
In-flight (waiting for DB)
0
Completed
Callback queue (ready to run)
Waiting for I/O (in-flight)
Event loop
Idle
Currently executing
The key takeaway

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.

What happens when you read a large file?

The scenario

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:

Your JS
calls fs.readFile
← event loop free, serving other requests →
callback runs
libuv
hands to thread pool → thread reads disk → signals completion
Thread
BLOCKING THIS THREAD — reading 500MB from disk
t=0mst=200mst=400mst=600mst=~800ms

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.

But here's the gotcha

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 — loads everything into RAM
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.

fs.createReadStream — processes in chunks
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.

Quick check: Your server reads a 1GB video file with fs.readFile. 50 users do this at once. How much RAM does your server need minimum?

What if all 4 thread pool threads are busy?

Scary scenario

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:

libuv thread pool
Thread 1
free
Thread 2
free
Thread 3
free
Thread 4
free
Work queue (waiting for a free thread)
— empty —
Event loop status
RUNNING — serving other requests freely
What you just saw

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?

Goes through thread pool
fs.readFile / fs.writeFile
crypto (bcrypt, pbkdf2, scrypt)
dns.lookup
zlib compression
custom C++ native modules
OS async — bypasses thread pool
http.request / fetch
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.

The real enemy: blocking the event loop

Here's what kills Node servers

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:

Requests per second
0
Event loop status
Idle

Common ways developers accidentally block the event loop

JSON.parse on large objects
const data = JSON.parse(req.body); // 10MB JSON = ~200ms of blocking
readFileSync (the worst offender)
const file = fs.readFileSync('big.log'); // blocks for the entire read duration
Heavy computation in a route handler
app.get('/calc', (req, res) => {
  let sum = 0;
  for (let i = 0; i < 1e9; i++) sum += i; // blocks for seconds
  res.json({ sum });
});
Synchronous crypto or image processing
const hash = crypto.pbkdf2Sync(pw, salt, 100000, 64, 'sha512'); // BLOCKS
Which of these WILL block the event loop and freeze all other users?

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);

Handling ZIP files the right way

Real scenario

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:

WORSTreadFileSync + unzipSync
const buf = fs.readFileSync('upload.zip');  // BLOCKS event loop
const files = unzipSync(buf);               // BLOCKS event loop again
// All other users frozen during both operations
BADreadFile (async) + unzip entire buffer
fs.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!
});
GOODStreaming with unzipper library
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.
BEST (for large files)Streaming + Worker Thread for heavy validation
// 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:

📦
Flat RAM usage
Only the current chunk (64KB) lives in memory. A 10GB file still uses ~64KB of RAM.
Faster time-to-first-byte
User starts receiving output while the file is still being processed. No waiting for the whole thing.
🔒
Event loop stays free
Each chunk triggers a quick callback. No single callback hogs the loop for 500ms.
You're building a ZIP download endpoint. Users request 2GB ZIP files. Which approach is correct?

The complete cheat sheet — no doubts left

Everything we covered, distilled into clear rules. If you remember nothing else, remember these:

Node IS single threaded...
...in the sense that your JavaScript runs on one thread. All callbacks execute one at a time, sequentially. But each callback is done in <1ms, so this is never the bottleneck.
I/O is truly parallel
Network calls (fetch, http, sockets) — the OS handles thousands simultaneously. File I/O — 4 worker threads run in parallel. Your JS thread never waits.
Thread pool ≠ event loop
The 4 threads handle: file I/O, crypto, DNS lookup, zlib. They can be exhausted. Solution: UV_THREADPOOL_SIZE=16, or use streams to avoid blocking threads.
The real danger
Synchronous JS that takes too long. readFileSync, JSON.parse(10MB), heavy loops. These block the event loop and freeze ALL users. Use async versions or Worker Threads.
Always stream large files
fs.createReadStream, not fs.readFile. archiver/unzipper with pipes, not buffer-based APIs. RAM stays flat. Event loop stays free. Users stay happy.
CPU work → Worker Threads
Image processing, bcrypt, custom algorithms, video encoding — these go in Worker Threads or child_process. They run on truly parallel OS threads, isolated from your event loop.

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.

You now understand Node.js at a systems level.
Most developers never get this deep. You know why async exists, what actually runs in parallel, what blocks the loop, and how to handle files correctly. Go build things.