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.

nodejslibuvevent-loopbackendsystems

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.

JavaScript
Node.js C++ bindings
libuv
OS-specific APIs
Kernel
Hardware

Examples:

Linux epoll, pthreads, sockets, filesystem syscalls
macOS kqueue, pthreads, sockets, filesystem syscalls
Windows IOCP, Winsock, Windows threads

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:

  1. Run timers
  2. Run pending callbacks
  3. Run idle and prepare callbacks
  4. Poll for I/O
  5. Run check callbacks
  6. Run close callbacks
  7. 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 active
No timers
No idle handles

libuv 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 ms
TCP socket also active

libuv cannot sleep forever. It asks the OS:

Wake me when either:
1. I/O happens
2. 5000 ms passes

Conceptually:

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 = 0

That 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 instructions
The scheduler removes it from the CPU
Other threads/processes may run
CPU usage for that thread is basically zero

So 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:

Linux epoll
macOS/BSD kqueue
Windows IOCP
Older Unix select/poll

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.

Event occurs
Kernel records it
Sleeping thread becomes runnable
Scheduler eventually runs the thread
epoll_wait()/kevent()/IOCP call returns
libuv dispatches callbacks
Node.js runs JavaScript callback

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 queue
Thread gets no CPU time
Kernel remembers what it is waiting for

Later, when an event happens:

Network packet arrives
Socket becomes readable
Kernel sees that the libuv thread was waiting for this
Thread state changes from SLEEPING to RUNNABLE

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 pointer
Stack pointer
CPU registers
CPU flags
Scheduling state
Wait reason

The thread’s stack and heap remain in memory. It is not “frozen to disk” or anything heavy like that.

Later:

Event happens
Thread marked runnable
Scheduler picks it
Kernel restores its CPU state
System call returns
libuv continues

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:

Socket becomes readable
epoll/kqueue/IOCP reports event
event loop wakes
libuv callback
Node.js callback

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.

Node.js
libuv filesystem request
queue work item
worker thread performs blocking read()
worker thread finishes
worker notifies event loop
Node.js callback runs

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 job
Worker thread executes that job
Worker thread reports completion
Worker thread returns to the pool

Example:

Read fileA offset 0
Read fileB offset 0
Read fileA offset 65536
Close fileB
Read fileC offset 0

Any available worker can execute any job.

A possible schedule:

Worker 1 read a.txt chunk 1
Worker 2 read b.txt chunk 1
Worker 3 read a.txt chunk 2
Worker 1 close b.txt

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 usage
Long delay before callback
Possible process crash if file is too big

So 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 KB
Emit chunk
Read next 64 KB
Emit chunk
Read next 64 KB
Emit 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:

Node asks for a read
libuv worker reads one chunk
worker reports completion
Node receives chunk
Node decides whether to ask for another read

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:

Buffer below highWaterMark
Node schedules another uv_fs_read()
Buffer at/above highWaterMark
Node stops scheduling more reads
Consumer drains buffer
Node schedules another read

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 offset
start
end
highWaterMark
bytesRead

Each time Node wants another chunk, it submits a read request with a specific offset.

Conceptually:

read fd at offset 0, length 64 KB
read fd at offset 65536, length 64 KB
read 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 += bytesActuallyRead

The 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 operation

14. 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:

open file worker job worker returns to pool
read chunk 1 worker job worker returns to pool
read chunk 2 worker job worker returns to pool
read chunk 3 worker job worker returns to pool
close file worker job worker returns to pool

The file descriptor is tracked by Node’s stream object.

The worker only temporarily handles concrete operations like:

open
read
close

15. 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/O
DNS lookups via getaddrinfo/getnameinfo
Crypto operations
Compression / zlib
Custom work scheduled with uv_queue_work()

In Node.js terms:

fs.readFile() worker pool
fs.createReadStream worker pool per read job
dns.lookup() worker pool
crypto.pbkdf2() worker pool
zlib.gzip() worker pool

But typical TCP/UDP socket I/O does not use the worker pool in the same way.

net.createServer() event loop / OS polling
socket.on("data") event loop / OS polling
setTimeout() event loop timer logic

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 loop

The 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:

One JavaScript execution context
One event loop

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 C

Each worker has its own:

JavaScript execution context
V8 isolate
event loop
microtask queue
timers

So 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 B

something would need to schedule between them:

Run Loop A
Run Loop B
Run Loop A
Run Loop B

But 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 connections

The event loop is not one-loop-per-resource. It is one loop managing many handles.


19. Final mental model

Here is the whole picture:

JavaScript code
Node.js APIs
Node C++ bindings
libuv
libuv has:
  • event loop
  • handles
  • requests
  • worker thread pool
  • OS-specific backend

For sockets:

Socket registered with OS
event loop sleeps in poll
kernel wakes event loop when socket is ready
libuv dispatches callback
Node runs JS callback

For filesystem streams:

Node stream decides it wants data
Node submits read request with offset and size
libuv queues job to worker pool
worker performs blocking read
worker reports completion
event loop wakes
Node receives chunk
JS consumes chunk
Node decides whether to request another chunk

For sleeping:

event loop calls OS wait function
kernel saves thread state
thread marked sleeping
thread removed from runnable queue
CPU runs something else
event or timeout happens
thread marked runnable
scheduler runs it
OS wait function returns
libuv continues

For worker pool jobs:

Worker does not own a resource.
Worker owns one job at a time.
open job
read job
read job
close job
crypto job
dns job
zlib job

For Node event loops:

One main JS thread one main event loop
Worker Threads:
  • 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

TermMeaning
Event loopThe loop that waits for events and dispatches callbacks
Poll timeoutHow long the loop can sleep while waiting for I/O
Blocking syscallA system call that may put the thread to sleep
Sleeping threadA thread waiting for an event, not consuming CPU
Runnable threadA thread ready to run but not necessarily currently running
Running threadA thread currently executing on a CPU
Context switchSaving one thread’s CPU state and running another thread
epoll/kqueue/IOCPOS-specific event notification mechanisms
Worker poolA group of native threads used for blocking/expensive jobs
BackpressureFlow-control mechanism that prevents unlimited buffering
highWaterMarkStream buffer threshold that affects when more data is requested
V8 isolateA separate JavaScript engine instance/context
Worker ThreadA Node.js thread with its own JS context and event loop