ENGINEERING COMMAND CENTER

LIVE ENGINEERING STATUS
Available for Full-Time Roles
Open to opportunities worldwide
RoleSenior Full Stack Engineer
Focus AreaBackend • Cloud • AI
Experience5+ Years
TimezoneFlexible
CurrentlyActively Looking
← Back to Engineering Articles
Category: Node.js

Understanding the Node.js Event Loop

1. Why I wrote this

Node.js is frequently celebrated for its ability to handle thousands of concurrent operations on a single thread. However, many developers write blocking asynchronous code that inadvertently locks this thread, leading to catastrophic lag. I wrote this guide to detail the inner mechanics of the libuv event loop to help developers write highly concurrent backend routers.

2. The Problem

Since JavaScript is single-threaded, if a CPU-heavy task (like hashing a password or parsing a massive JSON payload) is executed on the main call stack, it blocks the thread. While that task is running, the server *cannot* respond to any other client requests, check database results, or emit WebSockets messages, resulting in severe timeouts.

3. Core Concept

Node.js uses a single-threaded event loop architecture combined with a multi-threaded C++ backend (via libuv) to run asynchronous operations. When an async query starts, libuv delegates it to the OS kernel or thread pool and immediately returns execution to the main stack. When the task completes, the callback is pushed to an event loop queue.

4. How it works

The event loop executes in 6 distinct phases in a continuous cycle: 1. Timers: Executes callbacks scheduled by setTimeout() and setInterval(). 2. Pending Callbacks: Executes I/O callbacks deferred from the previous cycle. 3. Idle, Prepare: Used only internally by the system. 4. Poll: Retrieves new I/O events; server waits here if no timers are active. 5. Check: Executes callbacks scheduled by setImmediate(). 6. Close Callbacks: Handles close events (e.g. socket.on('close')).

Between each phase, the event loop drains two Microtask Queues:

  • process.nextTick() Queue (highest priority microtask).
  • Promise Microtask Queue (handles then/catch and resolved await triggers).

5. Architecture or Flow

The event loop execution cycle and phase transitions are illustrated below:

text
       +---------------------------------------------+
       |             JavaScript Call Stack           |
       +---------------------------------------------+
                              |
                     ( Non-Blocking Asynchronous )
                              |
                              v
       +---------------------------------------------+
       |             libuv Thread Pool               |
       |  (Handles CPU/File I/O tasks in background) |
       +---------------------------------------------+
                              |
                       ( Task Complete )
                              |
                              v
   +=================== Event Loop Cycle ===================+
   |                                                        |
   |   [ Phase 1: Timers ] -------> setTimeout/Interval     |
   |            |                                           |
   |      ( Run Microtask Queues: nextTick & Promises )     |
   |            |                                           |
   |   [ Phase 2: Pending ] ------> Deferred I/O Callbacks  |
   |            |                                           |
   |   [ Phase 3: Poll ] ---------> Fetch New I/O Callbacks |
   |            |                                           |
   |   [ Phase 4: Check ] --------> setImmediate            |
   |            |                                           |
   |   [ Phase 5: Close ] --------> socket.destroy()        |
   |                                                        |
   +========================================================+

6. Code Example

Below is a code example demonstrating how different macro and micro tasks execute in the event loop:

javascript
const fs = require('fs');

console.log('1. Start Call Stack');

// Macro task 1: Timer (Phase 1)
setTimeout(() => {
  console.log('2. setTimeout (Timer Phase)');
}, 0);

// Macro task 2: Check (Phase 4)
setImmediate(() => {
  console.log('3. setImmediate (Check Phase)');
});

// Micro task 1: process.nextTick
process.nextTick(() => {
  console.log('4. process.nextTick (Microtask)');
});

// Micro task 2: Resolved Promise
Promise.resolve().then(() => {
  console.log('5. Promise then (Microtask)');
});

// Macro task 3: File I/O
fs.readFile(__filename, () => {
  console.log('6. fs.readFile (Poll Phase)');
  
  // Inside Poll Phase, setImmediate always runs BEFORE setTimeout!
  setTimeout(() => console.log('7. Nested setTimeout'), 0);
  setImmediate(() => console.log('8. Nested setImmediate'));
});

console.log('9. End Call Stack');

Console Output Order: 1. 1. Start Call Stack 2. 9. End Call Stack 3. 4. process.nextTick (Microtask) (Runs immediately after call stack clear) 4. 5. Promise then (Microtask) (Drained right after nextTick) 5. 2. setTimeout (Timer Phase) 6. 3. setImmediate (Check Phase) 7. 6. fs.readFile (Poll Phase) 8. 8. Nested setImmediate (Inside Poll callback, setImmediate is queued in Check and runs first) 9. 7. Nested setTimeout (Timer runs in the next loop cycle)

7. Security or Performance Considerations

Tip
Avoid Thread Blocking: Never run mathematical computations, hashing (e.g. sync Bcrypt), or massive array sorting inside Express request handlers. If a calculation takes 500ms, the server is dead to all other users for 500ms. Mitigation: Offload CPU-heavy operations to Worker Threads (worker_threads module) or background task queues (e.g. BullMQ fronted by Redis).
  • Throttling WebSockets: Streaming drivers' locations in real-time produces hundreds of database inserts per second. If the Node thread is constantly handling socket write callbacks, the garbage collector will choke. Throttling inserts to a batch interval (e.g. once every 3 seconds) frees the loop.

8. Common Mistakes

  • Mixing process.nextTick and setImmediate: process.nextTick does not run in a loop phase; it runs *immediately* when the current operation completes, before the event loop continues. Infinite recursive calls to process.nextTick will starve the event loop entirely, freezing I/O processes.
  • Synchronous File Systems: Using fs.readFileSync inside routes blocks the event loop call stack. Always use fs.promises.readFile to ensure the file handle is delegated asynchronously to libuv's pool.

9. Where I applied it

I encountered event loop starvation in storefleet when handling real-time GPS coordinate broadcasts for 100+ trucks. The thread was overwhelmed by virtual DOM triggers. Resolving this required implementing a Redis-backed Socket room filter and throttling updates, keeping loop cycles under 15ms.

10. Key Takeaways

  • The event loop executes asynchronous callbacks in distinct phases (Timers, Poll, Check).
  • Microtasks (nextTick, resolved Promises) have priority and run immediately between phases.
  • Keep the main call stack light. Offload heavy computation to separate threads or workers.
  • Avoid using synchronous APIs (readFileSync, JSON.parse on huge strings) in server routes.
  • StoreFleet - Optimized Socket broadcasts to maintain low loop latencies.
  • CivicPulse AI - Implemented async queues for Gemini classification queries.
  • How JWT Authentication Works Under the Hood - Stateless security architectures.