Single-Threaded, Non-Blocking I/O
This is the single most important concept in Node.js. Internalize it and everything else — callbacks, promises, streams, performance tuning, scaling — falls into place. Misunderstand it and you will write servers that mysteriously freeze under load. We will build the idea up carefully, contrast it with the traditional model, prove it with runnable code, and then catalogue exactly what blocks the loop and how to avoid it.
The traditional model: thread-per-request
Classic servers (Apache with PHP, traditional Java servlet containers) assign each incoming connection its own operating-system thread. That thread runs the request to completion — including sitting idle while it waits for the database or disk. With 10,000 simultaneous connections you have ~10,000 threads, each consuming stack memory (often 1 MB+) and forcing the OS to constantly context-switch between them.
The hidden waste: in a typical web request, most of the wall-clock time is spent waiting on I/O, not computing. Thread-per-request burns a whole thread to do that waiting.
The Node model: one loop, many connections
Node.js runs your JavaScript on one thread driven by an event loop. When a request needs to wait — a database query, a file read, an upstream API call — Node does not block that thread. It delegates the waiting to the OS kernel or the libuv thread pool, registers a callback, and immediately moves on to the next piece of ready work. The thread is only ever busy doing actual work, never idling on a wait.
Aspect | Thread-per-request | Node.js event loop |
|---|---|---|
Threads for 10k connections | ~10,000 | 1 (for your JS) |
Memory per connection | High (thread stack) | Low (a callback + small state) |
Cost of an idle/waiting connection | A blocked thread | Almost nothing |
Context-switch overhead | High | Minimal |
Weakness | Memory under high concurrency | CPU-bound work blocks everyone |
Blocking vs non-blocking — prove it
Blocking — the program waits
import fs from 'node:fs'
console.log('A')
const data = fs.readFileSync('big.txt') // nothing else runs until done
console.log('B')
console.log('C')A B C
Non-blocking — the program stays responsive
import fs from 'node:fs'
console.log('A')
fs.readFile('big.txt', (err, data) => {
console.log('B') // runs later, when the read finishes
})
console.log('C')A C B
In the non-blocking version, C prints before B because the read happens in the background while the main thread continues. The callback fires only once a result is ready — exactly the behaviour that lets one thread serve many clients.
How one thread serves thousands of clients
The magic is delegation. Your thread never waits; it hands slow work to the layers below and processes their results when they arrive:
One thread, many in-flight operations
Request 1 ─┐
Request 2 ─┤ main thread accepts each, fires off async I/O,
Request 3 ─┤ and moves on — never waiting
... │
▼
┌──────────────────┐
│ event loop │ runs short callbacks as results arrive
└──────────────────┘
▲ ▲
kernel thread pool
(network) (files, dns, crypto)Why blocking is catastrophic on a server
Because one thread serves all clients, a single blocking operation in one request stalls every other request and timer simultaneously. The usual offenders:
fs.readFileSyncand any other*SyncAPI in a request handler.A long
for/whileloop doing heavy computation.JSON.parse/JSON.stringifyon a very large payload.A catastrophic-backtracking regular expression (ReDoS).
Synchronous crypto on the main thread (e.g. a large
bcryptcost factor run synchronously).
The classic mistake — a CPU loop freezes the whole server
import http from 'node:http'
http.createServer((req, res) => {
if (req.url === '/report') {
// Blocks the ENTIRE event loop for ~2s.
// During this time, NO other request — not even /health — is answered.
const end = Date.now() + 2000
while (Date.now() < end) {}
res.end('report ready')
} else {
res.end('ok')
}
}).listen(3000)Measuring whether you are blocking
A practical way to detect a blocked loop is to measure event-loop lag — how late a setInterval fires compared to its schedule. Healthy apps show sub-millisecond lag; a blocked loop shows large spikes.
loop-lag.js
let last = Date.now()
setInterval(() => {
const now = Date.now()
const lag = now - last - 500 // expected gap is 500ms
if (lag > 50) console.warn(`Event loop lag: ${lag}ms`)
last = now
}, 500)Production tools (perf_hooks.monitorEventLoopDelay, Clinic.js, APM agents) formalize this. We cover them in Performance Overview.
Scaling past one CPU core
One Node process uses one core for JavaScript. To use a whole machine, run multiple processes — with the cluster module, a process manager like PM2, or multiple container replicas behind a load balancer. CPU-bound work within a process belongs in Worker Threads. The single-threaded model is about how one process stays responsive; horizontal scaling is a separate, complementary concern.
Key takeaways
Your JavaScript runs on one thread; concurrency comes from delegating I/O, not from many threads.
Non-blocking APIs let the thread stay busy with useful work instead of waiting.
A single blocking call freezes all clients — never block the loop.
Node excels at I/O-bound concurrency; CPU-bound work must be offloaded.
Scale CPU with multiple processes; keep each process responsive by not blocking.