3.6
The Event Loop and Virtual Threads
How Node.js and Nginx serve many clients on few threads, what blocks the loop, and the virtual-thread alternative.
40 min Reading + animations
Tip: tap or hover any dotted-underlined term to see what it means.
By the end of this lesson you will be able to
- Define an event loop, and explain why one thread can serve thousands of connections as long as it never waits and never works for long.
- Say which work in a Node.js process runs on the main thread, which on libuv’s worker pool and which in the kernel, and diagnose a saturated pool.
- Walk through Node’s six phases, and predict the output of code that mixes
process.nextTick, Promises,setTimeoutandsetImmediate. - Recognise what blocks a loop, measure it with event-loop lag, and fix it with worker threads, slicing or a separate service.
- Tell blocking, non-blocking and asynchronous apart.
- Explain how Nginx and Redis really use event loops, and what the “single-threaded” label gets wrong about each.
- Explain green threads, goroutines and Java virtual threads, including pinning, and how they fail.
- Choose between an event loop, a thread pool and virtual threads for a given workload, and defend the choice in an interview.
#Where this lesson fits
Lesson 1.2 compared the ways a server handles many clients and lesson 1.13 showed Nginx’s master and workers. Lesson 2.5 and lesson 2.6 showed why long-lived connections need something cheaper than a thread each. In this chapter, lesson 3.1 defined threads, lesson 3.2 showed what a context switch costs, lesson 3.3 covered thread pools and lesson 3.5 showed how epoll tells a program which sockets are ready.
This lesson builds on all of that. epoll only answers “what is ready?”. The event loop is the program structure that sits on top of it: what runs, in what order, and what happens when something takes too long. Then we look at the other answer to the same problem, virtual threads, and at how to choose. Races and locks in code belong to Chapter 14.
#What an event loop is
An event loop is a programming model in which one loop keeps taking events and running the callback or task for each one. JavaScript and Node.js, Python’s asyncio and many other event-driven runtimes are built on it. The notes draw it like this:
Event created
|
v
Event queue
|
v
Event loop
|
v
Execute callback
|
v
Wait for events
|
+---- repeat
In code, the idea is only this:
while there is still work pending:
events = ask the kernel what is ready, sleeping if nothing is
for each event:
run its callback to completion
Two rules follow, and the rest of this lesson is really about them:
- Waiting costs almost nothing. A connection waiting for data is a small record in the kernel and a small object in your program, not a thread. So one thread can hold tens of thousands of mostly idle connections.
- Working blocks everyone. Each callback runs to completion and nothing can interrupt it. While one runs, every other connection on that loop waits.
So an event loop gives you concurrency, not parallelism: many things in progress, one running at a time (lesson 3.2). It fits I/O-bound work, where a request spends most of its life waiting. It is a poor fit for CPU-bound work, where there is nothing to wait for and the work must simply be done.
#The smallest Node.js server
The notes’ example is a complete server:
const http = require("http");
const server = http.createServer((req, res) => {
res.end("Hello World");
});
server.listen(3000);
There is no loop in this code, and no thread is created. server.listen registers a socket and the script ends, but the process does not exit, because the runtime’s event loop now has something to wait for. Your arrow function runs later, once per request.
#“Single-threaded” is only half true
Your JavaScript normally runs on one main thread. That does not make Node.js a single-threaded system. A running Node process has several threads, and three different parties do the work:
| Work | Who does it | Waits on the main thread? |
|---|---|---|
Your JavaScript: handlers, callbacks, JSON.parse | Main thread | It is the main thread’s work |
| Network sockets (HTTP, database drivers, Redis) | The kernel, reported through epoll, kqueue or IOCP | No |
File system calls (fs.*) | libuv’s worker pool | No |
dns.lookup, which http.get uses by default | Worker pool | No |
Async crypto.pbkdf2, crypto.scrypt, crypto.randomBytes | Worker pool | No |
Async zlib compression | Worker pool | No |
| Garbage collection (partly) and compiling your code | Helper threads inside the V8 engine | Mostly no, but some GC pauses stop the main thread |
libuv is the C library underneath Node.js that provides the event loop and the worker pool. The pool has 4 threads by default, set with the UV_THREADPOOL_SIZE environment variable when the process starts. Why does it exist? Regular files have no useful readiness signal: a regular file always reports “ready” (lesson 3.5), and the read may still block on the disk. So libuv runs a plain blocking call on a pool thread and reports back when it finishes. The same trick covers getaddrinfo, which has no non-blocking version.
The most useful fact in the table: the pool is not used for network sockets. Ten thousand HTTP connections use no pool threads at all. But four slow file reads, or four stuck name lookups, fill the pool completely.
One JavaScript thread, but not one thread doing everything
Follow a network request, a file read and two ways to run out of pool threads. Watch who actually waits in each case.
Scenario
The notes’ http.createServer server. A socket is network I/O, so no pool thread is ever involved.
server.listen(3000) registers the listening socket with the kernel. Then the script ends, but the process stays alive, because an open server is pending work for the loop.
#Node’s event loop, phase by phase
The notes list six phases, and each one has its own queue of callbacks:
| # | Phase | What it runs | Matters to you? |
|---|---|---|---|
| 1 | Timers | Expired setTimeout and setInterval callbacks | Yes |
| 2 | Pending callbacks | A few system callbacks put off from the last turn, such as some TCP errors | Rarely |
| 3 | Idle, prepare | Node’s internal bookkeeping | No |
| 4 | Poll | Asks the kernel what is ready and runs I/O callbacks. Sleeps here if there is nothing to do | Most of a server’s life |
| 5 | Check | setImmediate callbacks | Yes |
| 6 | Close callbacks | close events, such as socket.on("close", …) | Sometimes |
Three details are worth remembering:
- How long poll sleeps. If a
setImmediateis waiting, poll does not sleep at all. Otherwise it sleeps until the next timer is due, or indefinitely if there are no timers. That is why an idle Node process uses almost no CPU. - A timer’s delay is a minimum.
setTimeout(fn, 100)means “not before 100 ms”. If a callback is running at that moment, the timer waits for it. - When the process ends. After each turn the loop checks for live handles (a listening server, an open socket, a timer) and outstanding requests. If there are none, the process exits by itself.
timer.unref()tells the loop not to count a timer.
The diagrams usually show timers first. Since libuv 1.45 (Node.js 20), timers run only after poll rather than both before and after it, as the Node.js documentation notes. The cycle is the same, so all the ordering rules below still hold, but the first turn differs slightly from older descriptions.
Node.js event loop: one turn through six phases
Each phase has its own queue of callbacks. Step through a normal turn, a loop with nothing to do, a late timer and the moment the process exits.
Scenario
Between every callback the loop also empties the microtask queues; the next animation shows that.
This phase runs
| Phase | Callbacks |
|---|---|
| timers | setTimeout and setInterval callbacks whose time has come |
The loop checks the clock and runs every timer that has expired. A timer time is a minimum, not a promise.
#Microtasks, and who runs next
Outside the phases, Node has two more queues, and these are what make ordering puzzles interesting:
- The
process.nextTickqueue, which belongs to Node. - The Promise microtask queue, which belongs to the JavaScript engine:
.thencallbacks, code after anawait, andqueueMicrotask.
The notes mention both together with “a separate scheduling behaviour”. The precise rule is this:
- Run the current piece of synchronous code to completion.
- Empty the nextTick queue completely.
- Empty the Promise microtask queue completely. If a microtask added more ticks, go back to step 2.
- Only now run the next callback, whether in this phase or the next one.
Since Node.js 11 this check happens after every single callback, not only between phases. That matches how browsers behave.
#The notes’ example
console.log("Start");
setTimeout(() => {
console.log("Timer");
}, 0);
Promise.resolve().then(() => {
console.log("Promise");
});
console.log("End");
Start
End
Promise
Timer
Synchronous code first, so Start and End. Then the Promise, as a microtask. The timer last, because it needs the loop to reach a phase. A delay of 0 does not mean “now”: Node turns it into 1 ms, and either way it must wait for the stack to clear.
Who runs next? Call stack, microtasks and the loop’s queues
Each scenario runs a real snippet. Predict the output before you press next. Every order here was checked on Node.js 24.
Scenario
The notes’ example: why a 0 ms timer still loses to a Promise.
console.log("Start");setTimeout(() => {console.log("Timer");}, 0);Promise.resolve().then(() => {console.log("Promise");});console.log("End");
Call stack
running now
nextTick queue
emptied first
Promise microtasks
emptied next
Timers phase
Poll phase
Check phase
Console output
The whole script is one synchronous task on the call stack. Start prints immediately.
#process.nextTick runs before Promises, usually
Add process.nextTick(() => console.log("Tick")) to that script and, in a classic script run by node order.js, it prints between End and Promise, even if the Promise was scheduled first. So the two are not one queue, which corrects the notes.
There is a trap. Rename the file to order.mjs and Promise prints before Tick. A JavaScript module’s top-level code itself runs from inside the microtask queue, so that queue finishes draining before Node looks at its tick queue. Both results are correct. Code that depends on this order is fragile, so do not write it. Prefer queueMicrotask for “soon”, as the Node.js documentation also recommends.
#setImmediate vs setTimeout(0)
| Where you call them | Which runs first | Why |
|---|---|---|
| In the main script | Either | The 0 is really 1 ms. If that has passed when the loop starts, the timer runs first; if not, the immediate does. It varies with machine load |
| Inside an I/O callback | Always setImmediate | You are in poll, and check comes next. Timers only come on the next turn |
Use setImmediate when you want “after the current batch of I/O” and when you break up long work, because it lets I/O run in between. Recursive nextTick or Promise chains starve the loop: their queues must be empty before anything else runs, so a queue that refills itself holds up timers and I/O for as long as it continues.
#await is not a thread
await does not block anything and does not start a thread. It saves the function’s state, ends the current task and schedules the rest for later. Everything between two awaits runs without interruption. Two consequences catch people out:
- An
asyncfunction full of CPU work blocks the loop as much as any other function.asyncdoes not move work anywhere. - With no threads there are no data races inside one callback, but logic races across an
awaitare still possible:const x = await read(); await write(x + 1);can lose an update if another request interleaves between the two lines. Chapter 14 covers this.
Self-checkQuestion
A script in a plain .js file calls setTimeout(a, 0), then Promise.resolve().then(b), then process.nextTick(c), then console.log(d). In what order do they run?
#What blocks the loop
The notes’ example is the extreme case:
while (true) {
// infinite loop
}
Other requests are not handled, timers do not fire and callbacks stay stuck. This is called blocking the event loop. Real incidents are rarely while (true). They look like this:
| Cause | Typical cost | Why it surprises people |
|---|---|---|
JSON.parse or JSON.stringify on a large document | Roughly 100 ms or more for tens of MB | It feels like a trivial call. It grows with whatever size a client sends |
| Catastrophic regular expression | Seconds or more | It is fine in tests and an attacker can trigger it: ReDoS |
Synchronous APIs: fs.readFileSync, crypto.pbkdf2Sync, zlib.gzipSync | Disk or CPU time | Harmless at startup, a disaster in a handler |
| Password hashing on the main thread | About 100 ms by design | The async version exists and uses the pool |
| Sorting or looping over a huge array, large template renders, server-side rendering | Tens to hundreds of ms | It works at small data sizes and degrades as data grows |
| Long garbage collection pause | Tens of ms or more | Worse with a large heap, and no line of your code is at fault |
Blocking the loop: every request pays for one bad callback
The top lane is the thread that runs JavaScript. The lanes below are requests: grey means waiting for that thread. Compare a healthy loop, a slow callback, an infinite loop and the fixes.
Scenario
Each request needs about 5 ms of thread time. Nothing waits for anything.
The thread is mostly asleep in poll. All the time waiting on sockets and the database costs it nothing.
#The arithmetic of one thread
An event loop has a very simple capacity rule. If each request needs C milliseconds of time on the main thread, one loop can do at most 1000 / C requests per second:
| Main-thread time per request | Maximum per loop | What that means |
|---|---|---|
| 0.2 ms | 5,000 req/s | Thin proxy or small JSON API |
| 2 ms | 500 req/s | Typical business logic with serialization |
| 20 ms | 50 req/s | Heavy rendering; a 16-core machine still needs 16 processes |
Time waiting on the database does not count in C. That is the entire benefit. And near that limit latency explodes rather than rising gently, because arriving requests queue behind each other. Keep a loop well under its limit, about 60–70% busy at peak, so bursts have room.
A blocked callback also adds to everyone’s latency: if 1,000 requests per second are arriving and one callback holds the thread for 400 ms, 400 requests pile up and have to be drained afterwards.
#How to fix it
| Fix | How | Use it for | Trade-off |
|---|---|---|---|
| Use the async API | fs.promises, crypto.pbkdf2, a non-blocking driver | Anything with an async version | Competes for the pool |
| Worker threads | node:worker_threads, often through a pool library such as Piscina | CPU work that must answer the same request: image resizing, big parses | Data is copied between threads; each worker has its own engine and takes memory |
| Slice and yield | Do a small batch, then await new Promise(setImmediate) | Your own long loops | Still one core; the job takes longer; useless for one big library call |
| Separate service or queue | A job queue (Chapters 30–32) or a dedicated service | Video, reports, machine learning, anything measured in seconds | More moving parts; the answer becomes asynchronous |
| Limit the input | Body-size limits, streaming parsers, regular-expression engines without backtracking | Anything clients control | Some requests are refused |
| More processes | cluster, PM2, more containers | Using all cores | Does not make one request’s block any shorter, but limits the damage to that process |
Worker threads and processes are different tools. cluster adds capacity by running one loop per core. Worker threads protect the loop from a heavy job. A healthy Node service often uses both.
#Event-loop lag: the metric to watch
CPU does not tell you if a loop is healthy. One blocked thread on a 16-core machine is about 6% total CPU. The direct measure is event-loop lag, also called event-loop delay: how late a timer fires compared with when it was due. If a timer is meant to fire every 20 ms and it fires after 320 ms, the loop was busy for about 300 ms, and any request arriving then waited as long.
Node has this built in:
const { monitorEventLoopDelay, performance } = require("node:perf_hooks");
const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();
let last = performance.eventLoopUtilization();
setInterval(() => {
const now = performance.eventLoopUtilization();
const busy = performance.eventLoopUtilization(now, last).utilization;
last = now;
console.log(
`lag p99 ${(delay.percentile(99) / 1e6).toFixed(1)} ms, ` +
`busy ${(busy * 100).toFixed(0)}%`,
);
delay.reset();
}, 1000);
Read the two numbers together:
- Lag (nanoseconds in the histogram) shows how long a callback waited. An idle process reads about the timer resolution, not zero, so watch for growth above that baseline. Treat a sustained p99 of tens of milliseconds above it as a warning and hundreds as an incident; the right threshold depends on your latency target.
- Event loop utilization is the fraction of time the loop was doing work rather than sleeping in poll. It predicts saturation better than CPU does, because CPU also includes GC and other threads.
- Low utilization with occasional large lag spikes points to one bad callback. High utilization and rising lag point to too much load, so add processes.
Then find the culprit with a CPU profile (node --cpu-prof, or a flame graph from your APM). In production, export lag as a metric, alert on it, and consider shedding load when lag is high: returning 503 quickly is better than making everyone wait.
#Blocking, non-blocking and asynchronous
These three words are not synonyms. The notes’ table, sharpened a little:
| Blocking | Non-blocking | Asynchronous | |
|---|---|---|---|
| What the caller experiences | Waits until the result is ready | Returns at once, possibly with “not ready, try later” (EAGAIN) | Starts the work and is told when it completes |
| The thread | Sleeps | Keeps running | Depends on the implementation |
| Who finds out | The caller, when it wakes | The caller, by asking again or through a readiness API | Someone notifies the caller: callback, Promise, future |
| Programming style | Simple and sequential | More complex | Callback, future, Promise, await |
| Typical use | Simple I/O, scripts, thread pools | High-concurrency I/O, under an event loop | Modern application code |
Non-blocking and asynchronous are not the same thing. A non-blocking call returns immediately, whether or not it has done any work. An asynchronous operation reports its completion later. Node’s fs.readFile is asynchronous to your code, yet it is implemented with a blocking call on a pool thread. Sockets in Node are non-blocking underneath and appear to you as asynchronous. So “asynchronous” describes the interface your code sees, and “non-blocking” describes one system call. Lesson 3.5 goes through the kernel side.
#What event loops are good and bad at
Good:
- Very many concurrent connections that are mostly idle: chat, WebSockets, API gateways, proxies, streaming.
- Low memory use per connection, a few kilobytes, so high connection counts are affordable.
- Better use of resources, because while one request waits for I/O the thread serves others.
- One thread touching your data means no locks around in-memory state within a process.
Bad:
- CPU-heavy work, as shown above.
- Everything must cooperate. One library that blocks hurts all requests on that process, so isolation is poor.
- A single uncaught error can take down the whole process and all its connections.
- Callback-style code splits one logical operation across functions.
async/awaithides most of this, but stack traces and context propagation are still harder than in blocking code.
#asyncio: the same idea in Python
Python’s asyncio works the same way: one thread, one loop, coroutines that give the thread back at each await. On Linux it uses epoll underneath, and uvloop swaps in libuv itself for extra speed. The rule that catches most people:
import asyncio
import time
async def bad(name):
time.sleep(1) # blocks the thread, and the loop with it
print(name)
async def good(name):
await asyncio.sleep(1) # gives the thread back while waiting
print(name)
async def main():
start = time.perf_counter()
await asyncio.gather(good("a"), good("b"), good("c"))
print(f"{time.perf_counter() - start:.1f} s") # about 1.0, with bad() about 3.0
asyncio.run(main())
So a blocking call hidden inside async def, such as requests.get or a synchronous database driver, stalls every coroutine. Use an async library or push the call to a helper thread with await asyncio.to_thread(func). Run with asyncio.run(main(), debug=True) while developing and asyncio logs any callback slower than 100 ms. For CPU work in Python, threads are limited further by the GIL (lesson 3.2), so the usual answer is a process pool.
#Other event loops: Nginx and Redis
The notes group Node.js, Nginx and Redis together as a “single-threaded architecture”. All three use event loops, but they are built differently, and the difference matters in a design.
#Nginx: one loop per worker process
Nginx is not one event loop. It is one event loop per worker process, normally one worker per core (worker_processes auto;). Lesson 1.13 showed the master and the configuration; three more things matter here:
- Each connection belongs to one worker for its whole life. There is no shared request queue, so workers rarely need locks and the design scales nearly linearly with cores.
- Nginx runs no application code, only quick parsing, routing and copying, so its callbacks are short by nature. A slow upstream only means a socket stays in the
epollset for longer. - The weak spot is disk. Reading a file that is not in the page cache blocks the whole worker.
aio threads;sends those reads to a thread pool. It is exactly libuv’s trick in another product.
Nginx: one event loop per worker process
Not one loop for the whole server: one per worker, each owning its connections from start to finish.
Scenario
lesson 1.13 showed the master and workers. Here is what it means for the loop.
The kernel finishes the TCP handshake and queues the connection on a listening socket that all workers inherited from the master.
#Redis: one thread executes commands
“Redis is single-threaded” is true of command execution and of nothing else:
| Work | Thread |
|---|---|
| Executing commands and touching data | One main thread |
| Reading and writing sockets, parsing | Main thread, or optional I/O threads since Redis 6.0 (io-threads) |
Freeing large values (UNLINK), flushing the append-only file, closing files | Background threads |
| Snapshots and log rewrites | A forked child process (copy-on-write, lesson 3.4) |
Why choose one execution thread? Commands are in-memory and very fast, so the CPU is rarely the limit; there are no locks to take, and every command is atomic without any effort. Simple operations reach the order of 100,000 per second per instance on one core. To use more cores, you run more instances and partition the keys with Redis Cluster.
The event-loop rule applies exactly: a slow command freezes every client. KEYS *, DEL on a huge key, a large SMEMBERS, or a long Lua script all stop everything. So use SCAN, UNLINK and small batches, and watch SLOWLOG. Chapter 23 covers Redis as a cache.
Redis: one thread executes every command
That single thread gives you simple atomic commands, and makes one slow command everybody’s problem.
Scenario
Two clients increment the same counter at the same moment.
Client A’s command arrives. The event loop finds the socket ready and executes the command.
#The other answer: cheap threads
Event loops solve the cost of waiting but make you pay in code: callbacks or async/await, and the rule that nothing may block. There is another route. Threads are expensive because of how the kernel implements them, so what if the runtime implemented them instead?
Why OS threads cost so much at this scale (lesson 3.1, lesson 3.5):
- Each has a large fixed stack reservation (typically 1 MB of address space for a Java thread on 64-bit Linux, of which the touched part stays resident).
- Creating one takes a system call and on the order of tens of microseconds.
- Switching between them goes through the kernel scheduler and disturbs CPU caches.
Green threads, or user-level threads, are scheduled by the language runtime rather than by the kernel. Many of them run on a few OS threads, which is called M:N scheduling. The idea is old: very early Java had green threads and dropped them, because they all ran on one OS thread and could not use several CPUs. What changed is that modern versions combine M:N scheduling with a non-blocking I/O layer built into the runtime.
Here is the key insight: underneath, these runtimes are event loops. When a goroutine or a virtual thread reads a socket, the runtime registers it with epoll and runs something else. The difference is who writes the suspend and resume. With async/await you mark every suspension point yourself. Here the runtime does it, so ordinary-looking code just works.
This also removes the “coloured functions” problem: in JavaScript or Python, async functions and plain functions are two kinds that do not mix freely, and one async call deep inside forces every caller up the stack to change. With goroutines and virtual threads, all functions are the same colour.
#Goroutines
In Go, every go f() starts a goroutine. They are the only concurrency model the language offers:
- Start small. A goroutine’s stack starts at about 2 KB and grows by copying when needed.
- M:N scheduling. The runtime runs goroutines on threads, with
GOMAXPROCSthreads running Go code at once. It defaults to the CPU count, and since Go 1.25 the default on Linux also respects a container’s CPU limit, which fixed a long-standing cause of throttling. - Work stealing. Each logical processor has a local run queue, and an idle one takes work from a busy one.
- Network I/O goes through the runtime’s poller (
epoll,kqueue, IOCP), so a waiting goroutine uses no thread. - Blocking system calls do hold a thread, so the runtime gives the processor to another thread, creating one if needed.
- Preemption. Since Go 1.14 a long-running goroutine is interrupted with a signal after about 10 ms, so a tight loop cannot monopolise a thread. An event-loop callback gets no such protection.
Unlike one event loop, this uses all cores with one program. In return, goroutines truly run in parallel, so shared data needs locks or channels, and data races are real (Chapter 14).
Goroutines: many cheap tasks on a few OS threads
You write ordinary blocking code. The Go runtime parks goroutines and reuses the threads underneath.
Scenario
A blocking-looking network read that does not block any thread.
go func() {resp, err := http.Get(url)if err != nil { return }defer resp.Body.Close()results <- resp.StatusCode}()
Thread 1 (running Go)
Thread 2 (running Go)
Run queue
ready goroutines
Network poller
epoll
Stuck in a system call
Parked
channel or lock
Two threads run two goroutines; G3 and G4 are ready and waiting. GOMAXPROCS, normally the CPU count, decides how many threads run Go code at once.
#Java virtual threads
Virtual threads became final in Java 21 through JEP 444 (September 2023), after previews in Java 19 and 20. Java 25 is the current long-term support release at the time of writing, with Java 21 still widely deployed. Their design goal is that you keep existing blocking code, but it no longer ties up an OS thread while waiting:
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
public class VT {
public static void main(String[] args) {
long start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
}));
}
System.out.println("done in " + (System.currentTimeMillis() - start) + " ms");
}
}
That runs 100,000 concurrent one-second sleeps and finishes in about 2 seconds on a laptop. With one OS thread each, most machines could not start them all.
How it works:
- A virtual thread mounts on a carrier, a normal OS thread from a small
ForkJoinPoolwith one carrier per core by default. - When it blocks in a JDK call that supports this (socket, sleep, lock, queue), the JVM copies its stack to the heap and it unmounts. The carrier picks up another virtual thread.
- When the wait ends, it becomes runnable and mounts again, perhaps on a different carrier.
Frameworks now expose this with a switch, such as spring.threads.virtual.enabled=true in Spring Boot 3.2 and later. Four rules follow from the design:
- Do not pool virtual threads. Pools exist because threads are expensive; these are not. Create one per task.
- Limit the scarce resource instead. A 200-thread pool was also, by accident, a limit of 200 database calls at once. With virtual threads you need an explicit
Semaphoreor a bounded connection pool, or a spike goes straight through to your database. - Watch out for pinning. See below.
- Be careful with
ThreadLocalcaches. Code that assumed a few hundred threads and stores a large buffer in each can use huge amounts of memory with a million.
Pinning means a virtual thread cannot unmount, so its carrier blocks too:
| Situation | Java 21–23 | JDK 24 and later (JEP 491) |
|---|---|---|
Blocking inside synchronized | Pinned | Not pinned |
Blocking in a ReentrantLock | Not pinned | Not pinned |
| A call through native code (or a foreign function) | Pinned | Still pinned |
| Waiting on file I/O | Holds the carrier; the runtime may add a temporary one | Same |
Because there are few carriers, pinning is serious: with all of them pinned, every virtual thread stops even though the CPU is idle. On Java 21, find these with the JFR event jdk.VirtualThreadPinned and replace synchronized around blocking calls with ReentrantLock, or move to JDK 24+.
Java virtual threads: mount, unmount, pin
Virtual threads run on a small pool of carrier threads. They leave the carrier while they wait, unless something pins them.
Scenario
The same blocking code you always wrote, with the thread released during the wait.
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {ex.submit(() -> {var body = httpGet(url); // waitssave(body);});}
Carrier 1
OS thread
Carrier 2
OS thread
Ready to run
Parked on the heap
stack saved as objects
Database connections
limit 50, 3 drawn
Waiting for a permit
Each task gets a new virtual thread; there is no pool of them to size. V1 and V2 are mounted on the two carriers. By default there are as many carriers as cores.
Virtual threads make waiting cheap; they do not make computing faster. They have no time slicing among themselves today, so CPU-bound tasks should stay on a fixed pool sized to your cores (lesson 3.3).
#The wider family
| Runtime | Model | Note |
|---|---|---|
| Rust (Tokio) | async/await with a multi-threaded work-stealing runtime | A task that never awaits can stall a worker; spawn_blocking is for blocking calls |
| C# / .NET | async/await compiled to state machines on a thread pool | Blocking on a task (.Result) can starve the pool, or deadlock under a synchronization context |
| Kotlin | Coroutines, suspend functions | Coloured, but built on the JVM; can run on virtual threads too |
| Erlang, Elixir | Lightweight processes with a preemptive scheduler | Preempted by work done, so one busy process cannot stall others; designed for huge connection counts |
| Netty, Vert.x, Project Reactor | Several event loops on the JVM | Used under high-performance Java; virtual threads reduce the need for reactive style in ordinary services |
| Envoy, HAProxy | One event loop per worker thread | The same shape as Nginx, using threads rather than processes |
#How to choose
| Thread pool | Event loop | Virtual threads or goroutines | |
|---|---|---|---|
| Cost of a waiting request | One thread: tens of KB to about 1 MB | A few KB | A few KB, growing as needed |
| Realistic concurrent waits on one machine | Hundreds to thousands | Tens or hundreds of thousands | Hundreds of thousands to millions |
| CPU-heavy work | Good; uses all cores | Bad; needs workers | Good; uses all cores (no faster than threads) |
| Using all cores | Built in | One process per core | Built in |
| Code style | Simple and sequential | async/await or callbacks | Simple and sequential |
| What a bad task does | Occupies one thread | Freezes everyone on that loop | Occupies one thread at worst; pinning or blocking is its special risk |
| Shared in-memory data | Needs locks | No locks within a process | Needs locks or channels |
| Limits load by itself | Yes: the pool size | No | No: add explicit limits |
| Typical failure | Pool exhaustion | A blocked loop | Pinning, leaks, overwhelming a database |
| Fits | Existing CPU and blocking workloads | Proxies, gateways, WebSocket hubs, Node.js teams | New Go services; Java services that wait on I/O |
A decision path that holds up in practice:
- Mostly CPU? A thread pool (or processes) with about one thread per core. Virtual threads and event loops do not help.
- Mostly waiting, with many connections? Any of the cheap options works. Pick by what your team and libraries already use, not by benchmarks, because at this level the database almost always limits you first.
- Mixed? Keep the I/O on the loop or on virtual threads, and send CPU work to a bounded pool. Separate them into different services once the CPU work is big enough to scale on its own.
- Only a few hundred concurrent requests? A plain thread pool is fine. Do not rewrite a working service to save threads you are not short of.
A real architecture often uses all three: Nginx (event loop) at the edge, a Node.js or Java virtual-thread API behind it, and a CPU-sized worker pool for image processing behind a queue.
#In an interview
- If someone says “Node can’t scale because it’s single-threaded”, separate concurrency from parallelism, name the limit (1000 / C per process), and say you would run one process per core behind a load balancer.
- For a WebSocket hub with a million idle connections, choose an event loop or goroutines, then point out the real limits: memory per connection, file descriptors, and fan-out work (lesson 2.6).
- If someone proposes virtual threads as a performance win, ask what the workload is. They improve throughput for waiting work, not latency for CPU work, and they remove the implicit limit that a thread pool gave you.
- Asked why Redis is “single-threaded but fast”, say: in memory, no locks, epoll, and short commands. Then add what breaks it: one slow command stops all clients.
- Always mention what you would measure: event-loop lag and utilization, pool queue depth, pinned-thread events, goroutine count.
#Try it yourself
Everything here needs only Node.js. The order and pool results below were checked on Node.js 24.
1. Predict, then run. Save as order.js, run node order.js, then copy it to order.mjs and run that. Only the position of Tick changes.
console.log("Start");
setTimeout(() => console.log("Timer"), 0);
Promise.resolve().then(() => console.log("Promise"));
process.nextTick(() => console.log("Tick"));
console.log("End");
2. See the pool limit. Save as pool.js. Four hashes finish at about the same time and the fifth takes about twice as long. Then try UV_THREADPOOL_SIZE=5 node pool.js (Linux or macOS) and the fifth catches up, if you have enough cores.
const crypto = require("node:crypto");
const start = Date.now();
for (let i = 1; i <= 5; i++) {
crypto.pbkdf2("secret", "salt", 500_000, 64, "sha512", () => {
console.log(`hash ${i} done at ${Date.now() - start} ms`);
});
}
3. Block a server and watch lag. Save as lag.js and run it.
const http = require("node:http");
const { monitorEventLoopDelay } = require("node:perf_hooks");
const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();
setInterval(() => {
console.log(`lag max ${(delay.max / 1e6).toFixed(0)} ms`);
delay.reset();
}, 1000);
http
.createServer((req, res) => {
if (req.url === "/block") {
const end = Date.now() + 3000;
while (Date.now() < end) {} // blocks everything
}
res.end("Hello World\n");
})
.listen(3000);
In a second terminal run the first command below, and immediately the second in a third terminal. The harmless / request takes almost 3 seconds, and lag jumps to about 3,000 ms.
curl http://localhost:3000/block
curl -w "took %{time_total}s\n" http://localhost:3000/
4. Starve the loop. Run this, then change the marked line and compare. The timer does not run until the nextTick chain ends; with setImmediate it runs almost immediately.
setTimeout(() => console.log("timer ran"), 0);
let n = 0;
function again() {
if (++n < 5e6) process.nextTick(again); // now try setImmediate(again)
else console.log("done, n =", n);
}
again();
5. A hundred thousand threads. With JDK 21 or later, save the Java example above as VT.java and run java VT.java. Change newVirtualThreadPerTaskExecutor() to newThreadPerTaskExecutor(Thread.ofPlatform().factory()) and see how far your machine gets. Save your work first, because it may struggle.
#Summary
Key points
- An event loop takes events and runs callbacks to completion. Waiting costs almost nothing; working blocks everyone. It gives concurrency, not parallelism, and fits I/O-bound work.
- Node.js runs your JavaScript on one thread, but the kernel handles sockets and libuv’s worker pool (4 threads by default,
UV_THREADPOOL_SIZE) handles file calls,dns.lookup, some crypto andzlib. A saturated pool is invisible to event-loop lag. - Phases: timers, pending, idle/prepare, poll, check, close. Poll is where a server lives and where it sleeps.
- Order: synchronous code, then the
nextTickqueue, then Promise microtasks, after every callback. In a JavaScript module’s top level the Promise queue goes first.setImmediatebeatssetTimeout(0)inside an I/O callback; in the main script either can win. - Blocking the loop freezes every client: big
JSON.parse, bad regular expressions, sync APIs. Capacity per loop is about 1000 / C per second. Fix with async APIs, worker threads, slicing withsetImmediate, or a separate service. - Measure event-loop lag and utilization, not CPU.
- Blocking, non-blocking and asynchronous are different things.
fs.readFileis asynchronous to you and blocking on a pool thread. - Nginx has one loop per worker process. Redis executes commands on one thread, has optional I/O threads since 6.0 and background threads for slow frees; one slow command stops all clients.
- Goroutines and Java virtual threads (Java 21) are event loops the runtime writes for you: sequential code, M:N scheduling, all cores. Their risks are pinning (
synchronizeduntil JDK 24 and JEP 491, native code still), leaks, and no built-in limit on load. - Choose by workload: CPU work on a core-sized pool, many waits on an event loop or virtual threads, and always limit the scarce resource explicitly.
Answer on your own first, then read the explanation, even when you got it right.
Self-checkQuestion
What are the two properties of an event loop that decide what it is good for?
Self-checkQuestion
A Node.js API holds 20,000 open HTTP connections. How many libuv pool threads do those connections occupy just by being open?
Self-checkQuestion
Latency rises only on requests that read files, while event-loop lag is flat, CPU is low and the disks are healthy. Meanwhile the service makes many outbound http.get calls and the DNS resolver has become slow. What is the most likely cause?
Self-checkQuestion
Choose two good fixes for the DNS and file-read problem in a Node.js service.
Choose 2
Self-checkQuestion
Five crypto.pbkdf2 calls start together on an 8-core machine with default settings. Each takes about 0.7 s. What do you see?
Self-checkQuestion
In which phase does a Node.js server spend most of its life, including when it is idle?
Self-checkQuestion
setTimeout(fn, 100) is set. At 95 ms a file callback starts and uses 10 ms of CPU. When does fn run?
Self-checkQuestion
Your service will not exit during a rolling deploy. server.close() has finished, yet the process stays up until it gets SIGKILL. What is the most likely reason?
Self-checkQuestion
Inside an fs.readFile callback you call setTimeout(a, 0) and setImmediate(b). Which runs first, and why?
Self-checkQuestion
Choose two statements that are true about process.nextTick in Node.js.
Choose 2
Self-checkQuestion
This runs in a plain script: setTimeout(() => { console.log(1); Promise.resolve().then(() => console.log(2)); }, 0); setTimeout(() => console.log(3), 0); What does Node.js 24 print?
Self-checkQuestion
What does this print? async function f() { console.log(1); await null; console.log(2); } f(); console.log(3);
Self-checkQuestion
A teammate says: “I wrapped our image resize in an async function, so it no longer blocks the loop.” The resize is a pure JavaScript loop with no await inside. What actually happens?
Self-checkQuestion
Requests to a Node.js service each need 4 ms of main-thread time and wait 80 ms on the database. What is the rough maximum throughput of one process?
Self-checkQuestion
An endpoint parses 40 MB JSON bodies on the main thread, and each parse takes 400 ms. Traffic is 1,000 requests per second overall. About how many requests pile up during one parse?
Self-checkQuestion
A Node.js process on a 16-core machine has a callback stuck in an infinite loop. What will the machine-wide CPU graph most likely show?
Self-checkQuestion
Clients say connections succeed but requests hang. The process is at 100% of one core. Why does the TCP connection still succeed?
Self-checkQuestion
Choose two metrics that tell you most directly that a Node.js event loop is in trouble.
Choose 2
Self-checkQuestion
An endpoint must return a resized image in the same request. Each resize takes 300 ms of CPU. What is the best design in a Node.js service?
Self-checkQuestion
Which correctly separates the terms?
Self-checkQuestion
Inside an async def in Python, a coroutine calls time.sleep(5). What happens to other coroutines on that loop?
Self-checkQuestion
Which description of Nginx is correct?
Self-checkQuestion
An Nginx server shows occasional stalls on all connections of one worker, only when serving rarely used large files from a busy disk. What fixes this?
Self-checkQuestion
Choose two statements that are true about Redis threads.
Choose 2
Self-checkQuestion
You must delete a Redis set with 30 million members during peak traffic. What should you run?
Self-checkQuestion
What is the main programming difference between an async/await event loop and goroutines or virtual threads?
Self-checkQuestion
A Java 21 service moves to virtual threads. Under load it freezes while CPU is near zero. A thread dump shows carriers stuck in calls inside synchronized blocks. What is happening, and what are the fixes?
Self-checkQuestion
A team replaces a 200-thread pool with virtual threads and no other changes. Under a traffic spike the database collapses. Why?
Self-checkQuestion
Which statement about goroutines is correct?
Self-checkQuestion
Choose two workloads for which a plain thread pool sized near the number of cores is the best fit among these options.
Choose 2
Self-checkQuestion
An interviewer says: “We should move to virtual threads because they make things faster.” What is the best reply?