Imagine a restaurant with one main waiter taking orders, plus a kitchen that can prep some jobs in the back. The waiter is the JavaScript thread: it can talk to one customer at a time, but it can keep the restaurant moving by handing off slow tasks.
The kitchen helpers are libuv and the thread pool. They take care of things like file work or other waiting tasks so the waiter does not stand around doing nothing.
The restaurant also has a strict routine: first it checks for table timers, then it looks for completed work, then it handles new arrivals, and so on. That routine is the event loop. Node.js is basically this system for running JavaScript efficiently without freezing everything when one task is slow.
What Node.js Architecture Really Means
Node runs JavaScript on V8, the same engine that executes JS very fast by compiling it into machine code. But Node itself is more than V8: libuv provides the event loop, async I/O coordination, and a small thread pool for work that cannot be done non-blockingly.
This matters because Node is great at handling many concurrent I/O requests, but it is not magically parallel for your JavaScript code. If you write CPU-heavy code on the main thread, you can still block the loop and delay every other request.
In interviews, the key question is usually: what runs on the main thread, what gets offloaded, and what order do callbacks fire in? If you can explain that flow clearly, you understand the core of Node’s architecture.
V8 Executes JavaScript; libuv Orchestrates Async Work
V8 is responsible for executing your JavaScript code. When your code runs, it runs on the main JS thread managed by Node, and V8 is the engine doing the actual evaluation and optimization.
libuv sits underneath and helps Node wait on async operations without freezing JS. It exposes the event loop and provides a bridge to OS-level async features, so Node can keep accepting work while callbacks are queued for later.
A useful mental model is:
V8 = executes JS
libuv = manages async infrastructure
OS/kernel = does actual I/O when possible
That division is why fs.readFile(), network events, and timers can feel asynchronous even though JS itself is single-threaded.
Event Loop Phases and Where Callbacks Land
The event loop runs in phases, and each phase has a job. The common interview order is:
timers: callbacks from setTimeout and setInterval
pending: some deferred system callbacks
poll: receives I/O callbacks and may wait for new I/O
check: setImmediate callbacks
close: close event callbacks like socket cleanup
The important trap is that callbacks do not run “whenever”; they land in specific phases based on how they were scheduled. If the poll phase has I/O ready, it can run those callbacks before moving on.
So when asked “what fires first?”, the answer is not just the API name. You need to know which phase it belongs to and whether the loop is already inside a phase when the callback becomes ready.
Thread Pool: Async Work That Still Uses Threads
Node is single-threaded for JS, but libuv’s thread pool gives it a small set of worker threads for certain blocking-style operations. This is how Node can keep the main thread responsive while some tasks run in parallel behind the scenes.
The thread pool is commonly used for:
fs operations
crypto operations
some zlib work
DNS lookups via getaddrinfo
This is not the same as making your JavaScript parallel. The callback still returns to the main event loop when the worker finishes. Also, the pool is limited in size, so if you queue many expensive tasks, they can backlog and hurt latency.
process.nextTick vs setImmediate
process.nextTick() does not wait for the next event loop phase. Its callbacks run before the event loop continues, after the current JavaScript stack finishes. That makes it a very high-priority queue.
setImmediate() runs in the check phase, which is later in the loop. In practice, nextTick usually beats setImmediate, especially when both are scheduled from top-level or from within I/O callbacks.
Interviewers love to test this ordering because it shows whether you understand Node’s internal queues versus the phase-based event loop. The trap is thinking both are just “next turn” APIs. They are not: nextTick is more urgent and can even starve the loop if abused.
Blocking the Loop and How to Avoid It
The main JS thread must stay responsive, so blocking the loop means doing work that prevents the event loop from reaching other callbacks. Common examples are huge JSON processing, tight CPU loops, or expensive synchronous APIs like fs.readFileSync().
When the loop is blocked, incoming requests, timers, and I/O callbacks all wait. That is why Node apps can look fine in light testing but fall apart under real traffic.
If the work is CPU-bound, move it off the main thread. Use Worker Threads for true parallel JavaScript execution, or split work into smaller chunks so the event loop can breathe between pieces.
Worked Ordering Example
Consider this code and the question: what order do the logs appear in, assuming no other work is pending?
The key is that synchronous code runs first, then nextTick, then promise microtasks, then the event loop phases such as timers and check.
Because setTimeout(..., 0) lands in timers and setImmediate() lands in check, their relative order can vary depending on context, but from top-level code setTimeout often appears first. Inside I/O callbacks, setImmediate often wins.
This is exactly the kind of subtle ordering interviewers ask about to see whether you know the queues, not just the API names.
If you need to hash a giant payload or compute something expensive, putting it directly in a request handler can freeze all other requests. Instead, move the CPU-bound work to a Worker Thread so the main loop stays free to accept new connections and process I/O.
This is different from async file or network work: those are often handled by libuv or the OS, while CPU-heavy JavaScript needs actual parallel execution. In interviews, a strong answer is to say that Workers are for CPU-bound tasks, not for “making async faster” in general.
The distinction matters because Node architecture is about keeping the event loop responsive first, and scaling compute separately when needed.
// Main thread
const { Worker } = require('worker_threads');
const worker = new Worker(`
const { parentPort } = require('worker_threads');
let sum = 0; for (let i = 0; i < 1e8; i++) sum += i;
parentPort.postMessage(sum);
`, { eval: true });
worker.on('message', (msg) => console.log('done', msg));