evolved.io logotype

#Node.js #JavaScript #Async #Promises #Event Loop #Performance

Promises, Async Functions, and the Event Loop

Concurrency in Node.js: Learn how Promises, async/await, and the event loop work together to handle thousands of operations without blocking.

Avatar
Dennis GaidelJanuary 17, 2025

Node.js's asynchronous architecture is built around the concept of non-blocking operations. But what does "non-blocking" really mean, and how does it work under the hood? Let's dive deep into the mechanics of async operations, Promises, and the event loop.

Understanding Non-Blocking Operations

In traditional blocking programming, when a program needs to wait for an operation (like reading a file or making a network request), it stops executing completely until that operation finishes. Imagine a waiter at a restaurant who can only serve one table at a time, waiting at the kitchen until each order is prepared before moving to the next customer.

Node.js, however, uses a non-blocking model. Like a skilled waiter who takes multiple orders and serves multiple tables while waiting for the kitchen to prepare meals, Node.js can initiate operations and continue executing other code while waiting for those operations to complete.

When we say Node.js is "non-blocking," we need to distinguish between two types of operations:

I/O Operations

When your code makes I/O calls (file operations, network requests), Node.js delegates these to the operating system through libuv. These operations are truly non-blocking because:

  • They are handled by the system's thread pool
  • The main JavaScript thread continues execution
  • When the I/O operation completes, the callback is queued for execution

CPU-Intensive Operations

CPU-intensive tasks behave differently:

  • They are not automatically delegated to the system
  • They will block the main thread even if wrapped in async/await
  • They require explicit handling through Worker Threads

Here's an example showing the difference:

// I/O Operation - Non-blocking
async function fetchData() {
  // This will be delegated to the system, allowing other code to run
  const response = await fetch('https://api.example.com/data');
  return response.json();
}
 
// CPU-Intensive Operation - Blocking
async function heavyComputation() {
  // This will block the main thread despite being async!
  for (let i = 0; i < 1000000000; i++) {
    // Heavy computation
  }
}
 
// Proper way to handle CPU-intensive tasks
const { Worker } = require('worker_threads');
 
async function properHeavyComputation() {
  return new Promise((resolve, reject) => {
    const worker = new Worker(`
      const { parentPort } = require('worker_threads');
 
      parentPort.on('message', (data) => {
        let result = 0;
        for (let i = 0; i < 1000000000; i++) {
          result += i;
        }
        parentPort.postMessage(result);
      });
    `, { eval: true });
 
    worker.on('message', resolve);
    worker.on('error', reject);
    worker.postMessage({});
  });
}

The Event Loop and Task Queues

The event loop is the heart of Node.js's non-blocking architecture, implemented by the libuv library - a C library that provides the event loop to Node.js and handles asynchronous operations.

While Node.js and V8 handle the JavaScript execution, libuv manages the event loop itself, thread pool, and system events.

The event loop manages different types of queues and determines the execution order of your code:

Understanding Tasks

A task is a unit of work the event loop must process. There are two important types of tasks you'll encounter:

Callbacks are functions that execute when an operation completes:

// This callback becomes a task when the timer expires
setTimeout(() => console.log('Later'), 1000);
 
// This callback becomes a task when file reading completes
fs.readFile('test.txt', (err, data) => {
    console.log('File read complete');
});

Important: Not every function passed as an argument is a callback in terms of the event loop:

// These are NOT event loop callbacks - they're just regular function parameters
['1', '2'].map(item => parseInt(item));
 
// This is also NOT an event loop callback
function processUser(user, callback) {
    callback(user.name);
}
processUser({ name: 'John' }, name => console.log(name));

Events are signals that something happened, with associated code to run:

// This code becomes a task each time a request arrives
server.on('request', (req, res) => {
    console.log('New request');
});

These tasks are organized into different queues based on their priority:

Microtask Queue

  • Highest priority queue
  • Handles Promise callbacks (.then, .catch, .finally)
  • Processes async function continuations after await
  • Contains process.nextTick callbacks

Macrotask (Event) Queue

  • Lower priority queue
  • Handles setTimeout, setInterval callbacks
  • Contains I/O events and operations

Task Processing Order

  1. Current synchronous code completes (current task)
  2. All microtasks are processed in order
  3. One macrotask is taken from the queue
  4. Return to step 1

This sequence is crucial to understand. Let's see it in action:

// This demonstrates the exact execution order
console.log('1: Sync code starts');  // Synchronous code
 
setTimeout(() => {
  console.log('4: Macrotask executes');  // Macrotask queue
}, 0);
 
Promise.resolve().then(() => console.log('3: Microtask executes'));  // Microtask queue
 
console.log('2: Sync code ends');  // Synchronous code
 
// Output:
// 1: Sync code starts
// 2: Sync code ends
// 3: Microtask executes
// 4: Macrotask executes

Understanding Promises and Async Functions

Mozilla's definition link:

The Promise object represents the eventual completion (or failure) of an asynchronous operation and its resulting value.

It can be in one of three states:

  • Pending: Initial state
  • Fulfilled: Operation completed successfully
  • Rejected: Operation failed

Async functions build on top of Promises, providing a more intuitive syntax. When you declare a function as async:

  • It automatically ensures the function returns a Promise
  • It allows the use of await inside the function
  • The function's execution can be "paused" at await points

Here's how await actually works:

async function demonstration() {
  console.log('1: Function starts');
 
  // When await is encountered:
  // 1. Function execution is suspended
  // 2. The rest of the function is wrapped in a Promise
  // 3. Control returns to the event loop
  await new Promise(resolve => setTimeout(resolve, 1000));
 
  // This code becomes a microtask, executing only after
  // the Promise resolves and the microtask queue is processed
  console.log('3: After await');
}
 
console.log('Starting demonstration');
demonstration();
console.log('2: This runs before await resolves');
 
// Output:
// Starting demonstration
// 1: Function starts
// 2: This runs before await resolves
// (after 1 second)
// 3: After await

Promise Resolution Pipeline

Understanding how asynchronous operations flow from system-level code back to JavaScript requires following the complete pipeline.

Let's break it down:

1. Thread Pool Execution

When you call an async function (like reading a file), the following happens:

  • Node.js delegates the operation to libuv
  • libuv assigns the work to a thread from its thread pool
  • The thread executes native C/C++ code for the operation
  • Your JavaScript continues executing other code

2. Completion Detection

When the operation completes:

  • The thread stores the result (or error) in memory
  • A C++ completion callback is registered with libuv
  • The thread becomes available for other work

3. Event Loop Bridge

During the event loop's poll phase:

  • libuv detects completed operations
  • The C++ completion callback executes
  • Node.js creates a new task containing the operation's result
  • This task is added to the macrotask queue

4. Promise Resolution

When the event loop processes the task:

  • The JavaScript Promise associated with your async call is resolved (or rejected)
  • This creates a new microtask containing your continuation code
  • The microtask queue is processed immediately
  • Your .then() handler or await continuation finally executes

This pipeline ensures that system-level operations can safely return values to JavaScript while maintaining the JavaScript single-threaded execution model. Every Promise you create in Node.js follows this path when it involves system operations like:

  • File operations
  • Network requests
  • Cryptographic operations
  • DNS lookups

For pure JavaScript Promises (like Promise.resolve()), the pipeline is simpler since no thread pool or system operations are involved.

System-Level Handling of Multiple Operations

When multiple I/O operations are submitted:

  • Node.js uses libuv's thread pool (default size: 4 threads)
  • Network I/O typically doesn't use the thread pool (handled by OS)
  • File operations are queued in the thread pool
  • Operations are processed in parallel up to the thread pool size
  • Additional operations wait in a queue

You can adjust the thread pool size:

// Set this before your application starts
process.env.UV_THREADPOOL_SIZE = '8';  // Increase thread pool to 8

This architecture allows Node.js to handle thousands of concurrent operations efficiently, even though JavaScript itself runs on a single thread. The combination of the event loop, task queues, and system delegation creates a powerful system for handling asynchronous operations while maintaining application responsiveness.