Article
libuv, Node.js, Event Loops, OS Sleeping, and the Worker Pool
A practical mental model for libuv, Node.js event loops, OS sleeping, filesystem I/O, streams, backpressure, worker threads, and the libuv worker pool.
Personal Intent
This article is based on a discussion with GPT while reading through the libuv documentation, asking follow-up questions, and refining the mental model around libuv, Node.js, the event loop, OS notification APIs, sleeping threads, filesystem I/O, streams, backpressure, worker threads, and multiple event loops.
My goal is to preserve the mental model in one place so I can quickly rebuild the whole picture later.
1. libuv in one sentence
libuv is a user-space, cross-platform event-loop library that gives Node.js a unified API over different operating-system mechanisms.
Your code does not directly use Linux epoll, macOS/BSD kqueue, or Windows IOCP. Instead, Node.js uses libuv, and libuv maps its own uv_* APIs to the best mechanism available on the current OS.
Examples:
libuv itself lives in user space. It cannot directly receive hardware interrupts, schedule CPU threads, or access kernel internals. It asks the kernel to do those things through system calls.
2. Event loop stages and the poll timeout
The event loop repeatedly moves through these stages:
- Run timers
- Run pending callbacks
- Run idle and prepare callbacks
- Poll for I/O
- Run check callbacks
- Run close callbacks
- Repeat the cycle
Before the loop enters the poll stage, libuv must decide whether it can sleep, and for how long. The poll timeout is the value that answers this question:
How long can the event loop safely sleep while waiting for OS events?
The loop cannot simply sleep forever, because timers might need to fire. But it also should not spin constantly, because that would waste CPU.
So before entering the poll phase, libuv calculates a timeout based on active work.
Example: only a TCP server, no timers
TCP server activeNo timersNo idle handleslibuv can tell the OS:
Sleep until some I/O happens.Conceptually:
epoll_wait(..., timeout = infinite);The event loop thread consumes almost no CPU while waiting.
Example: a timer expires in 5 seconds
Timer expires in 5000 msTCP socket also activelibuv cannot sleep forever. It asks the OS:
Wake me when either:1. I/O happens2. 5000 ms passesConceptually:
epoll_wait(..., timeout = 5000);If a socket event happens after 300 ms, the OS wakes the loop early.
If nothing happens, the timeout expires and the loop wakes to run the timer.
Example: an idle handle is active
If an active idle handle exists, libuv should not sleep:
poll timeout = 0That means:
epoll_wait(..., timeout = 0);The OS returns immediately, and the loop continues running.
3. Does libuv “sleep without blocking”?
This is the subtle but important point:
libuv does block the event-loop thread during the poll phase, but it blocks it in the kernel in a way that does not consume CPU.
Blocking is not always bad.
Bad version:
while (1) { check_for_events();}This is busy-waiting. It burns CPU.
Good version:
epoll_wait(...);This enters the kernel and says:
Put this thread to sleep until something relevant happens.
While sleeping:
The thread is not executing instructionsThe scheduler removes it from the CPUOther threads/processes may runCPU usage for that thread is basically zeroSo the event loop blocks, but it does not waste CPU.
4. How OS notification works
Modern operating systems expose APIs that allow a program to register interest in events.
Examples:
The program says:
I am interested in this socket.Wake me when it becomes readable or writable.The OS does not call JavaScript directly. Instead, the system call returns.
5. What does “sleeping thread becomes runnable” mean?
The OS scheduler sees threads in states.
A simplified model:
RUNNING The thread is currently executing on a CPU core.
RUNNABLE The thread is ready to run, but waiting for CPU time.
SLEEPING / BLOCKED / WAITING The thread cannot continue until some event happens.When the event loop calls something like:
epoll_wait(...);and no events are available, the kernel puts the thread into a sleeping state.
That means:
Thread removed from runnable queueThread gets no CPU timeKernel remembers what it is waiting forLater, when an event happens:
Important distinction:
RUNNABLE does not mean RUNNING.It only means the thread is eligible to run. The scheduler still has to pick it and place it on a CPU core.
6. Is sleeping basically saving thread state and context switching?
Yes, that is the right mental model.
When the thread enters a blocking syscall and the kernel decides it must sleep:
1. The kernel saves the thread's execution context.2. The thread is marked sleeping/waiting.3. It is removed from the runnable queue.4. The scheduler chooses another runnable thread.5. A context switch happens.The saved state includes things like:
Instruction pointerStack pointerCPU registersCPU flagsScheduling stateWait reasonThe thread’s stack and heap remain in memory. It is not “frozen to disk” or anything heavy like that.
Later:
To libuv, it simply looks like:
epoll_wait(...)returned.
7. Sockets vs files: they are handled differently
This was one of the most important distinctions.
Sockets
Network sockets usually work well with readiness notification APIs:
This is the classic event-driven model.
Regular files
Regular filesystem reads are different.
On many platforms, reading a normal file can block. The OS generally does not provide the same style of readiness notification for regular disk file reads as it does for sockets.
So libuv usually handles filesystem operations through its worker thread pool.
So for filesystem I/O, the event loop itself is not sitting in epoll_wait() waiting for the file to become readable in the same way it would for a socket.
Instead:
Filesystem I/O is often moved to the worker pool.8. The worker pool executes jobs, not resources
This was another key insight.
The worker thread does not “own” a file.
It does not attach itself to a file stream for the lifetime of that stream.
Instead:
Worker thread takes one jobWorker thread executes that jobWorker thread reports completionWorker thread returns to the poolExample:
Read fileA offset 0Read fileB offset 0Read fileA offset 65536Close fileBRead fileC offset 0Any available worker can execute any job.
A possible schedule:
The worker pool is job-based.
The file stream state lives higher up in Node.js.
9. What happens with fs.readFile()?
When you use:
fs.readFile("big-file.bin", (err, data) => { // callback receives whole file});the operation is conceptually one high-level request.
From JavaScript’s point of view:
No chunks are delivered while reading.One callback runs when the full data is ready.For a very large file, this can be dangerous:
Large memory usageLong delay before callbackPossible process crash if file is too bigSo fs.readFile() is not the best choice for huge files.
10. What happens with fs.createReadStream()?
For large files, streams are the better model.
Example:
const stream = fs.createReadStream("huge-file.bin");
stream.on("data", (chunk) => { console.log(chunk.length);});Now the file is read incrementally.
Conceptually:
Read 64 KBEmit chunk
Read next 64 KBEmit chunk
Read next 64 KBEmit chunk...Each chunk corresponds to a separate filesystem read operation.
11. Does the worker thread keep pushing chunks forever?
No.
For streams, the worker thread does not autonomously keep reading the file and pushing chunks into JavaScript.
The model is closer to:
So:
If Node does not submit another read request, the worker thread will not start reading the next chunk for that stream.
The worker thread is not in control of the stream. Node is.
12. Pull-based reading and backpressure
Streams use backpressure.
Node keeps an internal buffer. The size threshold is controlled by highWaterMark.
For file read streams, the default chunk size is commonly 64 KB.
const stream = fs.createReadStream("huge-file.bin", { highWaterMark: 64 * 1024});The logic is roughly:
This prevents unbounded memory growth.
So stream reading is not worker-side pushing. It is:
Demand-driven pulling with backpressure.13. Who controls the file offset?
Node’s stream layer controls the read position.
For a stream, Node tracks state like:
current offsetstartendhighWaterMarkbytesReadEach time Node wants another chunk, it submits a read request with a specific offset.
Conceptually:
read fd at offset 0, length 64 KBread fd at offset 65536, length 64 KBread fd at offset 131072, length 64 KB...The libuv worker receives a specific job:
Read N bytes from file descriptor FD at offset X.After the read finishes, Node updates its stream state:
offset += bytesActuallyReadThe ownership is:
Node.js ReadStream owns stream state and next offset
libuv fs request carries one offset for one operation
worker thread executes that one read operation14. What happens when the file is closed?
Closing the file is just another filesystem operation.
It is not the thing that “returns the worker thread to the pool,” because the worker thread already returns to the pool after every individual job.
For a stream:
The file descriptor is tracked by Node’s stream object.
The worker only temporarily handles concrete operations like:
openreadclose15. Are files the only thing using the worker pool?
No.
The worker pool is commonly used for operations that may block or are CPU-heavy.
Examples:
Filesystem I/ODNS lookups via getaddrinfo/getnameinfoCrypto operationsCompression / zlibCustom work scheduled with uv_queue_work()In Node.js terms:
But typical TCP/UDP socket I/O does not use the worker pool in the same way.
A useful rule:
If the OS can notify libuv when a resource is ready, libuv can use the event loop. If the operation would block or burn CPU, libuv often moves it to the worker pool.
16. Does Node.js have only one event loop?
The precise answer is:
A typical Node.js main thread has one event loop. But a Node.js process can have multiple event loops when using Worker Threads.
libuv itself supports multiple loops:
uv_loop_t loop1;uv_loop_t loop2;
uv_loop_init(&loop1);uv_loop_init(&loop2);You can run different loops on different native threads.
But Node.js normally uses this structure:
Main Thread V8 isolate JavaScript execution Event loopThe reason is mostly simplicity and safety.
JavaScript code in a given V8 isolate is single-threaded. That avoids shared-state race conditions.
If multiple event loops executed callbacks against the same JavaScript heap, code like this would become dangerous:
counter++;Two loops could mutate the same state at the same time.
So Node’s model is:
17. Worker Threads and multiple event loops
Modern Node.js supports Worker Threads.
With workers, the architecture becomes:
Main Thread V8 Isolate A Event Loop A
Worker Thread 1 V8 Isolate B Event Loop B
Worker Thread 2 V8 Isolate C Event Loop CEach worker has its own:
JavaScript execution contextV8 isolateevent loopmicrotask queuetimersSo Node can have multiple event loops, but not multiple event loops freely sharing one JavaScript execution context.
18. Why not multiple event loops in one thread?
A single OS thread can only run one instruction stream at a time.
If one thread had multiple event loops:
Thread +-- Loop A +-- Loop Bsomething would need to schedule between them:
Run Loop ARun Loop BRun Loop ARun Loop BBut that becomes another event loop above the event loops.
Usually, it gives no benefit.
One event loop can already manage many resources:
1 event loop 10 sockets 100 timers 1000 streams 100000 connectionsThe event loop is not one-loop-per-resource. It is one loop managing many handles.
19. Final mental model
Here is the whole picture:
- event loop
- handles
- requests
- worker thread pool
- OS-specific backend
For sockets:
For filesystem streams:
For sleeping:
For worker pool jobs:
Worker does not own a resource.Worker owns one job at a time.
open jobread jobread jobclose jobcrypto jobdns jobzlib jobFor Node event loops:
- each worker thread has its own JS context and event loop
20. The shortest possible version
If I only remember one thing, it should be this:
libuv is a user-space abstraction over OS event mechanisms and blocking-operation offloading. The event loop sleeps efficiently by letting the kernel block the thread until an event or timeout occurs. Network I/O usually uses OS readiness/completion notifications. Filesystem I/O usually goes through the worker pool. Node streams do not let workers push data endlessly; Node submits read jobs as needed, tracks offsets, and applies backpressure. A typical Node main thread has one event loop, while Worker Threads can have their own separate event loops.
21. Vocabulary recap
| Term | Meaning |
|---|---|
| Event loop | The loop that waits for events and dispatches callbacks |
| Poll timeout | How long the loop can sleep while waiting for I/O |
| Blocking syscall | A system call that may put the thread to sleep |
| Sleeping thread | A thread waiting for an event, not consuming CPU |
| Runnable thread | A thread ready to run but not necessarily currently running |
| Running thread | A thread currently executing on a CPU |
| Context switch | Saving one thread’s CPU state and running another thread |
| epoll/kqueue/IOCP | OS-specific event notification mechanisms |
| Worker pool | A group of native threads used for blocking/expensive jobs |
| Backpressure | Flow-control mechanism that prevents unlimited buffering |
| highWaterMark | Stream buffer threshold that affects when more data is requested |
| V8 isolate | A separate JavaScript engine instance/context |
| Worker Thread | A Node.js thread with its own JS context and event loop |