If you’ve written JavaScript for a while, you’ve probably typed async, await, or .then() hundreds of times without really knowing what happens behind the scenes. You know Node.js is non-blocking and single-threaded, but how can something single-threaded handle thousands of concurrent requests?
In this post, we’ll finally make it click. We’ll walk through the call stack, the event loop, and the different task queues that make Node.js so efficient. No hand-waving, no jargon dumps, just clear analogies and short code snippets you can run today.
The Short Answer: How Does Node.js Handle Asynchronous Code?
Node.js handles asynchronous code by offloading slow operations (file reads, network calls, timers) to the system kernel or a thread pool, while the main JavaScript thread keeps running. When those operations finish, their callbacks are pushed into queues, and the event loop picks them up one by one and executes them on the call stack. There’s a good explainer over at w3schools.com.
In other words: JavaScript itself is synchronous and single-threaded, but Node.js gives it superpowers by combining it with libuv, a C library that manages the event loop and a thread pool for heavy tasks.

The Three Actors You Need to Understand
Before diving into the loop, meet the three main characters of the story: This write-up is worth a look.
- Call Stack: where JavaScript actually executes code, one function at a time.
- Node APIs / libuv: the background workers that handle I/O, timers, and networking.
- Task Queues: waiting rooms where finished callbacks line up before running.
Think of it like a restaurant:
- The chef (call stack) can only cook one dish at a time.
- The kitchen assistants (libuv thread pool) prep ingredients in the background.
- The waiter (event loop) constantly checks which dishes are ready and delivers them to the chef.
Step 1: The Call Stack in Action
The call stack is a simple LIFO (Last In, First Out) data structure. Every function call gets pushed on top and popped off when it returns.
function greet() {
console.log('Hello');
}
function start() {
greet();
console.log('Done');
}
start();
// Output:
// Hello
// Done
Nothing async here. The stack goes: start → greet → console.log, then unwinds. Easy. Now let’s break it.
Step 2: Adding Asynchronous Work
console.log('A');
setTimeout(() => {
console.log('B');
}, 0);
console.log('C');
// Output:
// A
// C
// B
Why does B come last even with a delay of 0? Because setTimeout is not a JavaScript function, it’s a Node API. Here’s what actually happens:
console.log('A')runs on the stack.setTimeouthands the callback to libuv and returns immediately.console.log('C')runs on the stack.- The stack is empty. The event loop checks the timer queue, finds the callback, and pushes
console.log('B')onto the stack.

Step 3: The Event Loop Phases
This is where most tutorials oversimplify. The event loop is not one queue, it has multiple phases that run in a specific order on every iteration (called a “tick”).
| Phase | What It Handles |
|---|---|
| Timers | Callbacks from setTimeout and setInterval. |
| Pending Callbacks | Some system-level callbacks (e.g. TCP errors). |
| Idle, Prepare | Internal use only. |
| Poll | Retrieves new I/O events (file reads, incoming connections). This is where Node spends most of its time. |
| Check | Executes setImmediate callbacks. |
| Close Callbacks | Callbacks like socket.on('close'). |
Between every phase, Node also drains two special microtask queues:
- process.nextTick queue (highest priority)
- Promise microtask queue (
.then,awaitresumptions)
Step 4: Microtasks vs Macrotasks
This is the source of 90% of the “why did this log in that order?” confusion. Look at this:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
// Typical output:
// sync
// nextTick
// promise
// timeout
// immediate
Order of execution priority:
- Synchronous code on the stack.
- process.nextTick callbacks.
- Promise microtasks.
- Event loop phases (timers, poll, check, etc.).
Step 5: Where Do Promises and async/await Fit?
A Promise is just a wrapper around asynchronous work. When you write:
async function loadUser() {
const user = await db.findUser(1);
console.log(user.name);
}
The await keyword pauses the function, returns control to the event loop, and schedules the rest of the function as a microtask once db.findUser resolves. Your function is essentially split into two parts: everything before the await, and everything after, which runs later as a callback.
Step 6: What About the Thread Pool?
Not everything is truly async at the OS level. Some operations (like fs.readFile, crypto.pbkdf2, DNS lookups) use libuv’s thread pool (4 threads by default). You can bump it up with:
process.env.UV_THREADPOOL_SIZE = 8;
Network I/O, on the other hand, uses the OS’s native async mechanisms (epoll on Linux, kqueue on macOS, IOCP on Windows), so it doesn’t consume thread pool slots.

Common Pitfalls to Avoid
- Blocking the event loop: heavy CPU work (big loops, JSON parsing of huge payloads, sync crypto) stops everything. Move it to
worker_threads. - Starving the loop with process.nextTick: recursive
nextTickcalls can prevent I/O from ever running. - Assuming setTimeout(fn, 0) runs immediately: it doesn’t. There’s always at least one loop iteration of delay.
- Not handling promise rejections: unhandled rejections crash your process in modern Node versions.
A Complete Mental Model
Put it all together:
- Node runs your top-level script on the call stack.
- Async APIs offload work to libuv or the OS.
- When the stack empties, Node drains nextTick then promise microtasks.
- The event loop enters its next phase (timers, poll, check…).
- For every callback executed, microtasks are drained again before moving on.
- Repeat until there’s no more work, then exit.
Once this picture is in your head, weird async bugs stop being weird. You start predicting the log order before running the code.
Key Takeaways
- Node.js is single-threaded for JavaScript but multi-threaded under the hood thanks to libuv.
- The event loop is the traffic controller that picks which callback runs next.
- Microtasks (nextTick, promises) always run before the next macrotask.
- Keep the loop free: never block it with CPU-heavy synchronous code.
- Understanding the phases turns you from a “callback user” into an actual Node.js engineer.
FAQ
Is Node.js really single-threaded?
The JavaScript execution is single-threaded, yes. But Node uses libuv, which manages a thread pool and leverages OS-level async I/O, so the overall process is multi-threaded.
What’s the difference between setImmediate and setTimeout(fn, 0)?
setImmediate runs in the check phase, right after the poll phase. setTimeout(fn, 0) runs in the timers phase. Inside an I/O callback, setImmediate is guaranteed to run before setTimeout(fn, 0).
Why is process.nextTick considered dangerous?
Because it runs before any other I/O or timer. If you recursively schedule nextTick callbacks, you’ll starve the event loop and block all real work.
Do promises use the same queue as setTimeout?
No. Promises use the microtask queue, which has higher priority and is drained between every phase (and between every callback) of the event loop. There’s a fuller breakdown if you want the detail.
When should I use worker_threads instead of async code?
Use worker_threads when you have CPU-bound work (image processing, big calculations, compression). Async patterns only help with I/O-bound work.
Does async/await make code slower than callbacks?
In modern Node.js versions (V8 has optimized async functions heavily), the overhead is negligible. Prefer async/await for readability. Performance differences only show in extreme hot paths.

