Exploring Node.js: How the Event Loop Determines Execution Order

Introduction

As a Node.js developer, understanding the event loop and execution order is crucial for writing efficient and bug-free applications. The event loop is the core mechanism that allows Node.js to perform non-blocking I/O operations despite JavaScript being single-threaded. Let's explore the key timing functions and their execution order

%%{init: {
  'theme': 'base',
  'themeVariables': {
    'primaryColor': '#34495e',
    'primaryTextColor': '#ffffff',
    'primaryBorderColor': '#2c3e50',
    'lineColor': '#2980b9',
    'secondaryColor': '#e74c3c',
    'tertiaryColor': '#8e44ad'
  }
}}%%
flowchart TD
    classDef highest fill:#e74c3c,stroke:#000,stroke-width:2px,color:#ffffff
    classDef high fill:#f39c12,stroke:#000,stroke-width:2px,color:#000000
    classDef medium fill:#3498db,stroke:#000,stroke-width:2px,color:#ffffff
    classDef low fill:#2ecc71,stroke:#000,stroke-width:2px,color:#000000
    classDef module fill:#34495e,stroke:#000,stroke-width:2px,color:#ffffff

    A["JavaScript Code Execution"] --> B["Call Stack"]
    B --> C{"Event Loop"}

    C --> D["Microtasks Queue"]
    D --> E["process.nextTick()"]
    D --> F["Promise callbacks"]

    C --> G["Macrotasks Queue"]
    G --> H["setImmediate"]
    G --> I["setTimeout/setInterval"]
    G --> J["I/O Events"]

    K["Module Loading"] --> L["CommonJS (sync)"]
    K --> M["ES Modules (async)"]

    E --> |"1st Priority"| N["Execution"]
    F --> |"2nd Priority"| N
    H --> |"3rd Priority"| N
    I --> |"4th Priority"| N
    J --> |"5th Priority"| N

    L --> |"Blocks"| B
    M --> |"Non-blocking"| C

    class E highest
    class F high
    class H medium
    class I,J low
    class L,M module

    %% Animations
    click E call callback() "Execute Microtask"
    click H call callback() "Execute Macrotask"

Key Timing Functions

1. process.nextTick()

This is not technically part of the event loop but runs immediately after the current operation completes. It has the highest priority and runs before any other timing functions.

2.Promise callbacks

Promise callbacks are executed in the microtask queue, making them a high-priority part of the event loop execution order. They run after process.nextTick() but before any macrotasks like setImmediate or setTimeout.

3. setImmediate()

setImmediate() schedules a callback to execute in the next iteration of the event loop. It runs in the check phase, after I/O events but before timers. While similar to setTimeout(fn, 0), setImmediate() is more efficient for executing callbacks immediately after I/O events.

Key characteristics of setImmediate():

  • Executes callbacks in the check phase of the event loop

  • Ideal for running code after I/O operations complete

  • More predictable than setTimeout(0) when used within I/O callbacks

  • Useful for breaking up CPU-intensive tasks into manageable chunks

When used inside an I/O cycle, setImmediate() callbacks are always executed before setTimeout() and setInterval() callbacks, making it particularly useful for I/O-related operations.

4. setTimeout() and setInterval()

These run in the timers phase of the event loop. Even setTimeout(fn, 0) will have a minimum delay of 1ms.

5. I/O event

I/O events are operations that interact with external resources like files, network, or databases. These events are processed in the I/O phase of the event loop after pending timers but before setImmediate() callbacks.

Examples

console.log("Start");

setTimeout(() => {
  console.log("setTimeout");
}, 0);
setImmediate(() => {
  console.log("setImmediate");
});

process.nextTick(() => {
  console.log("nextTick 1");
  process.nextTick(() => {
    console.log("nextTick 2");
  });
});

console.log("End");

// Output

Start
End
nextTick 1
nextTick 2
setImmediate
setTimeout

Module Systems and Timing

CommonJS (require)

In CommonJS, modules are loaded synchronously, and the code is executed immediately during the require() call. This means timing functions in the main module execute after all required modules are loaded.

graph TD
    A["require() call"] --> B["Check cache"]
    B -->|"Found"| C["Return cached"]
    B -->|"Not found"| D["Load & execute"]
    D --> E["Cache & return"]

    style A fill:#95a5a6
    style B fill:#f1c40f
    style C fill:#2ecc71
    style D fill:#e74c3c
    style E fill:#3498db

This diagram illustrates the synchronous nature of CommonJS module loading:

  1. When require() is called, Node.js first checks if the module is cached

  2. If cached, it returns the cached exports immediately

  3. If not cached, it loads and executes the module code synchronously

  4. The module.exports object is created and cached

  5. Finally, the exports are returned to the caller

This process blocks the event loop until the module is fully loaded and executed.

ES Modules (import)

ES Modules are loaded asynchronously and are always in strict mode. Top-level await is supported, which can affect the timing of execution.

graph TD
    A["Your Code"] --> B["Loading Phase"]
    B --> C["Building Phase"]
    C --> D["Running Phase"]

    B --> E["Get All Required Files"]
    E --> C

    C --> F["Set Up Everything"]
    F --> G["Connect All Parts"]

    D --> H["Run Your Code"]

    style A fill:#95a5a6
    style B fill:#4ecdc4
    style C fill:#f1c40f
    style D fill:#2ecc71
    style E fill:#3498db
    style F fill:#e74c3c
    style G fill:#9b59b6
    style H fill:#f39c12

The diagram above illustrates the ES Modules execution process:

  1. Construction Phase: Modules are parsed and dependencies are identified

  2. Module Graph: A complete graph of all dependencies is created

  3. Instantiation Phase: Module environments are created and exports/imports are linked

  4. Evaluation Phase: Module code is executed, handling any top-level await operations

This asynchronous process allows for better optimization and parallel loading compared to CommonJS.

Summary

  • The Node.js event loop processes tasks in a specific order: process.nextTick(), Promise callbacks (microtasks), setImmediate(), setTimeout/setInterval (macrotasks), and I/O events

  • Microtasks (process.nextTick and Promises) have the highest priority and execute before macrotasks

  • setImmediate() is optimized for executing callbacks after I/O events, making it more efficient than setTimeout(0)

  • CommonJS modules load synchronously and block execution, while ES Modules load asynchronously allowing for parallel processing

  • Understanding the event loop and execution order is essential for writing efficient Node.js applications and avoiding callback scheduling issues

0
Subscribe to my newsletter

Read articles from Nimatullah Razmjo directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Nimatullah Razmjo
Nimatullah Razmjo

Software Engineer with 9+ years of experience in the back-end, front-end, and DevOps generally focused on back-end