Chapter 04
A small intro to async vs sync
Note
Note: I am not going to explain the event loop just yet. We will have a dedicated chapter on it later in this book. Bear with me whenever I say "event-loop.”.
Note
As an asynchronous event-driven JavaScript runtime, Node.js is designed to build scalable network applications
The line above is the very first line you'd see on the About Node.js page. Let's understand what do they mean by **asynchronous event-driven** runtime.
In Node.js, doing things the asynchronous way is a very common approach due to its efficiency in handling multiple tasks at once. However, this approach can be complex and requires a careful understanding of the interplay between asynchronous (async) and synchronous (sync) operations.
This chapter aims to provide a comprehensive explanation of how these operations work together in Node.js. We will delve into the intricacies of async and sync operations, including how buffers can be used to fine-tune file calls.
Also, we will explore Node.js's smart optimization techniques, which allow for increased performance and responsiveness. By understanding the interplay between async and sync operations, the role of buffers, and the trade-offs involved in optimization, you will be better equipped to write efficient and effective Node.js applications.
The Balance between Opposites
Node.js's architecture is designed to support asynchronous operations, which is consistent with JavaScript's event-driven nature. Asynchronous operations can be executed via callbacks, Promises, and async/await. This concurrency allows tasks to run in parallel (not exactly parallel like multi-threading), which enables your application to remain responsive even during resource-intensive operations such as file I/O.
However, synchronous operations disrupts this balance. When a synchronous operation is encountered, the entire code execution is halted until the operation completes. Although synchronous operations can usually execute more quickly due to their direct nature, they can also lead to bottlenecks and even application unresponsiveness, particularly under high I/O workloads.
Mixing Asynchronous and Synchronous Code
You must ensure consistency between asynchronous and synchronous operations. Combining these paradigms can lead to a lot of challenges. The use of synchronous operations within an asynchronous context can result in performance bottlenecks, which can derail the potential of your application.
Every operation affects how quickly it responds to requests. If you use both async and sync operations, it can make the application slower and less efficient.
Faster I/O out of the box
Node.js uses buffering to handle file operations. Instead of writing data directly to the disk, Node.js stores the data in an internal buffer in memory. This buffer combines multiple write operations and writes them to the disk as one entity, which is more efficient. This strategy has two benefits: it's much faster to write data to memory than to disk, and batching write operations reduces the number of disk I/O requests, which saves time.
Node.js's internal buffering mechanism can make asynchronous write operations feel instantaneous, as they are merely appending data to memory without the overhead of immediate disk writes. However, it's important to note that this buffered data isn't guaranteed to be persisted until it's flushed to the disk.
Blocking Code
Blocking code refers to a situation where additional JavaScript execution in the Node.js process has to wait until a non-JavaScript operation finishes. This can occur because the event loop cannot continue running JavaScript when a blocking operation is in progress.
Consider the following example that reads a file synchronously:
const fs = require("node:fs");
// blocks the entire program till this finishes
const fileData = fs.readFileSync("/path/to/file");
console.log(fileData);
The readFileSync
method is blocking, which means that the JavaScript execution in the Node.js process has to wait until the file reading operation is complete before continuing. This can cause performance issues, especially when dealing with large files or when multiple blocking operations are performed in sequence.
Fortunately, the Node.js standard library offers asynchronous versions of all I/O methods, which are non-blocking and accept callback functions. Consider the following example:
const fs = require("node:fs/promises");
async function some_function() {
// blocks the execution of the current function
// but other tasks are unaffected
const data = await fs.readFile("test.js", "utf-8");
console.log(data);
}
some_function();
The readFile
method is non-blocking, which means that the JavaScript execution can continue running while the file reading operation is in progress. When the operation is complete, the next line is executed.
Some methods in the Node.js standard library also have blocking counterparts with names that end in Sync
. For example, readFileSync
, that we just saw, has a non-blocking counterpart called readFile
.
It's important to understand the difference between blocking and non-blocking operations in Node.js to write efficient and performant code.
Concurrency
One key aspect of Node.js is that JavaScript execution is single-threaded, meaning that only one task can be executed at a time. This can be a challenge when dealing with tasks that require a lot of processing power or that involve I/O operations, which can cause the program to block and slow down its execution.
To address this issue, Node.js uses an event-driven, non-blocking I/O model. This means that instead of waiting for an I/O operation to complete before moving on to the next task, Node.js can perform other tasks while the I/O operation is taking place.
For example, suppose an average request for an API endpoint takes 60ms. Within that 60ms, 30ms are spent reading from a database, and 25ms are spent reading from a file. If these process were synchronous, our web server would be incapable of handling a large number of concurrent requests. However, Node.js solves this problem with its non-blocking model. If you use the asynchronous version of the file/database API, the operation will continue to serve other requests whenever it needs to wait for the database or file.