Skip to main content
← Back to Articles

*Node.js Event Loop & Asynchronous Programming

What Actually Happens When You await

The Node.js Event Loop

A Recurring Misconception

There is a statement every JavaScript developer hears on day one:
“Node.js is single-threaded.”

It’s technically true — but practically dangerous.

This sentence often leads to a mental model that completely breaks down once your application hits real production traffic. It explains why simple code works perfectly on your laptop, but suddenly slows down or behaves unpredictably under load.

Have you ever debugged issues like these?

  • “Why is my API response time spiking even though server memory looks fine?”
  • “Why did this one await line block my entire server?”
  • “Why does setTimeout(0) actually take 5ms (or more) to run?”

If you treat Node.js like a traditional multi-threaded server (such as Go or Java), you will eventually block the Event Loop — and you may not even realize it.

By the end of this article, you won’t just know that Node.js is asynchronous.
You’ll understand exactly how Node executes code, what really happens when you write await, and how to ensure your production server never hangs.

JavaScript Is Single-Threaded (And Why That’s Not the Whole Story)

To understand Node.js’s asynchronous nature, we must first agree on the constraints of JavaScript itself.

JavaScript executes one function at a time on a single call stack

JavaScript has:

  • One Call Stack
  • One Main Thread

This means execution is deterministic:

  • Only one line of JavaScript executes at a time
  • Functions run to completion; they cannot be preempted mid-execution by the OS (unlike threads)
  • There is no parallel execution of JavaScript code within a single process

However, Node.js is not just JavaScript.

V8 runs JavaScript, while libuv handles I/O asynchronously via the OS.

Node.js is a runtime composed of:

  • V8, which executes your JavaScript
  • libuv, which handles operating system interactions such as file I/O, networking, and DNS

While V8 runs your JavaScript on a single thread, libuv performs I/O operations in the background, allowing Node.js to appear concurrent without running your JavaScript in parallel.

Synchronous vs Asynchronous Code (Mental Model Reset)

Forget technical definitions for a moment. Let’s focus on behavior.

Asynchronous code allows the call stack to continue serving new requests.

Synchronous Execution (Blocking)

This is the default execution model. Code runs top-to-bottom. If line 1 takes 5 seconds, line 2 waits 5 seconds.

  • Cost: The Call Stack is blocked
  • Danger: In a web server, while one request is being processed, all other requests must wait

Asynchronous Execution (Non-Blocking)

Asynchronous execution offloads waiting. You initiate a task (for example, reading a file) and provide a callback or promise to run when it completes.

  • Benefit: The Call Stack stays free to handle other requests
  • Important clarification: Asynchronous does not mean parallel. It means interleaved — doing useful work while waiting for something else to finish

The Event Loop — The Heart of Node.js

The Event Loop feeds tasks into the call stack whenever it becomes empty.

The Event Loop is the mechanism that allows Node.js to perform non-blocking I/O despite executing JavaScript on a single thread.

Think of the Event Loop as a traffic controller running in an infinite loop. Its responsibilities are simple:

  1. Check the Call Stack — is it empty?
  2. Check the task queues — are there callbacks ready to run?
  3. Push the next task onto the stack

This process repeats continuously. The Event Loop is what keeps your Node.js process alive.

Event Loop Phases (What Runs When)

The Event Loop is not a single bucket of work. It consists of distinct phases, and execution order matters.

Macro Task Phases (Simplified)

Node.js processes callbacks in well-defined phases, not randomly.

  • Timers: Callbacks from setTimeout() and setInterval()
  • Poll (I/O): Network and file system callbacks (where Node spends most of its time)
  • Check: Callbacks from setImmediate()
  • Close Callbacks: Cleanup events like socket.on(“close”)

The Priority Queue: Microtasks

Microtasks always drain completely before macrotasks are executed.

This is where many developers get tripped up.

There are two high-priority queues that run before the Event Loop proceeds to the next phase:

  • process.nextTick()
  • Promise callbacks (including await continuations)

Golden Rule:
Microtasks are executed immediately after the current operation completes, before the Event Loop advances to the next phase. If microtasks are continuously scheduled, the Event Loop can be starved indefinitely.

What Actually Happens When You await

Now let’s demystify await.

When you write:

js
const result = await fetchData();

You are not pausing the thread.
You are exiting the Call Stack.

The Lifecycle of await

  1. Execution Starts
    The function runs synchronously until it reaches await.
  2. Promise Creation
    The expression to the right of await executes and returns a Promise.
  3. Function Suspension
    Execution of the function stops. Its continuation is scheduled as a Promise reaction (a microtask).
  4. Stack Clears
    The function exits the Call Stack. The main thread is free to handle other work.
  5. Promise Resolution
    The external operation (database query, API call, etc.) completes.
  6. Microtask Queue
    The remaining part of the function is queued as a microtask.
  7. Execution Resumes
    Once the stack is empty, the Event Loop executes the microtask and the function continues.

Code Example

js
console.log("A");
async function run() {
  console.log("B");
  await fetchData();
  console.log("C");
}
run();
console.log("D");

Output:
A → B → D → C

This proves that await yields control back to the Event Loop, allowing other code to execute.

Why Blocking the Event Loop Is a Production Killer

Node.js serves many clients using a single execution thread. Fairness is critical. If one request performs heavy synchronous work, every other request must wait.

Common CPU-Heavy Blockers

  • Parsing large JSON payloads (JSON.parse)
  • Cryptographic sync methods (crypto.pbkdf2Sync)
  • Catastrophic regular expressions (ReDoS)
  • Image processing in pure JavaScript

The consequence:

If Request A blocks the thread for 200ms, Requests B, C, and D must wait 200ms just to start — even if CPU usage appears low.

How Node.js Handles Heavy Work (The Right Way)

Node.js is not designed to perform heavy computation on the Event Loop — but it can delegate it.

1. Worker Threads

Worker threads run JavaScript in separate threads with their own Event Loops.

Use for: CPU-intensive tasks like image resizing or complex calculations.

2. Child Processes

Spawn separate OS processes.

Use for: Running external scripts or isolated workloads.

3. Background Job Queues

Offload work entirely to workers using Redis or message brokers.

Use for: Emails, video processing, report generation.

Senior mindset:
Node.js is the orchestrator, not the worker.

Common Mistakes Juniors Make (And How to Avoid Them)

await inside forEach

Mistake:

js
items.forEach(async item => await save(item));

Reality: forEach does not await promises.

Fix: Use for…of or Promise.all.

Misusing Promise.all

Mistake: Running 1,000 DB inserts concurrently.

Reality: You can exhaust connection pools or memory.

Fix: Use batching or concurrency limits.

Assuming Async Means Parallel

Mistake: Assuming sequential awaits run concurrently.

Fix: Start promises first, then await Promise.all.

Blocking with Sync APIs

Mistake: Using fs.readFileSync inside request handlers.

Fix: Always use async APIs in server code.

When Node.js Is (and Isn’t) the Right Tool

Node.js Excels At:

  • I/O-heavy APIs
  • Real-time systems (WebSockets)
  • Service orchestration

Node.js Struggles With:

  • Heavy computation
  • Video encoding
  • ML model training

Offload or use languages like Go, Python, or Rust when appropriate.

The Mental Model to Keep

Node.js is a traffic controller, not a factory worker.

As long as nothing blocks the intersection (the Call Stack), thousands of requests can flow smoothly.

Recap:

  • One Call Stack
  • Event Loop schedules execution
  • await yields control via microtasks
  • Blocking kills throughput

Next step:
Open your current project. Look for await inside loops and any *Sync functions in API routes. Fixing those is often the fastest performance win you can make.

Further Reading


Originally published on Medium.