A thread waiting on a database call is doing nothing while it waits. Ten thousand concurrent requests, mostly waiting, means ten thousand mostly idle threads and ten gigabytes of stacks.
An event loop says: do not wait. Register what to do when the answer arrives, and go do something else.
The loop
while (running) { runUntilStackIsEmpty(); // execute current work to completion drainMicrotasks(); // all of them runOneMacrotask(); // then exactly one renderIfNeeded(); // browsers only}One thread, one queue of pending callbacks, one rule about which to run next. That rule is the part people get wrong.
What order does this print?
console.log('1');setTimeout(() => console.log('2'));Promise.resolve().then(() => console.log('3'));console.log('4');The answer is 1 4 3 2. Step through why:
console.log("1") setTimeout(() => console.log("2")) Promise.resolve().then(() => console.log("3")) console.log("4")
Synchronous. Runs immediately.
The one rule
Never block the loop. There is one thread; anything slow stops everything.
// A 200ms sort is 200ms of frozen UI, and 200ms of no requests served.const sorted = hugeArray.sort(compare);Nothing warns you. The page simply stops responding, and in Node the server stops accepting connections.
The fixes, in order of preference:
- Move it off-thread — a Web Worker in the browser,
worker_threadsin Node. This is the right answer for genuine computation. - Chunk it — process 1,000 items,
awaita macrotask, continue. Keeps the loop responsive at the cost of total throughput. - Do it elsewhere — a queue, another service, the database.
And beware the synchronous API: readFileSync, execSync, and
crypto.pbkdf2Sync all block the loop completely. In Node these exist for
startup scripts, not for request handling.
What you get for free
Because only one callback runs at a time, there are no data races. No mutexes,
no atomics, no memory model. Any sequence of statements without an await is
effectively atomic.
That is a real simplification, and it is why JavaScript has no synchronized
keyword.
Node’s loop has phases
Node’s macrotask side is not one queue but several, visited in order: timers,
pending callbacks, poll (I/O), check (setImmediate), close callbacks. Plus
process.nextTick, which runs before promise microtasks.
The practical consequences:
setImmediatefires after I/O in the current turn;setTimeout(fn, 0)fires in the next timers phase. Their relative order at the top level is genuinely nondeterministic.process.nextTickjumps ahead of promises, and anextTickloop starves the loop just as thoroughly as a microtask loop.
Reach for either only when you know which phase you need.