Why Async Code Exists in Node.js

Software Developer
A developer is building a simple Node.js application. The application allows users to upload a file, process its content, save the result to a database, and finally send an email notification.
At first, the workflow seems straightforward. The developer assumes that Node.js can execute these operations one after another without any issues. However, after a few days, more users start using the application, and performance problems begin to appear.
Some users upload large files that take several seconds to read.
Some database queries take longer than expected.
Sending emails depends on external services and network latency.
If Node.js waits for every operation to finish before moving to the next one, the server spends most of its time waiting. During this waiting period, other incoming requests are forced to wait as well. As the number of users increases, the application becomes slow and unresponsive.
Node.js was designed to avoid this situation.
Instead of waiting for a slow operation to complete, Node.js starts the operation and immediately continues executing other code. Once the operation finishes, Node.js receives the result and executes the appropriate logic.
This approach is known as asynchronous programming.
The primary goal of asynchronous programming is not to make operations faster. Reading a file still takes the same amount of time, and a database query still requires time to execute. The goal is to ensure that the application remains responsive while these operations are happening.
Because of this design, Node.js can handle many requests efficiently even though JavaScript executes on a single thread.
Callback-Based Async Execution
The earliest and most common way to handle asynchronous operations in Node.js was through callbacks.
A callback is simply a function that is passed as an argument to another function and executed later when an operation completes.
Consider a simple example of reading a file.
const fs = require("fs");
fs.readFile("notes.txt", "utf-8", (err, data) => {
if (err) {
console.log(err);
return;
}
console.log(data);
});
console.log("Program Finished");
Many beginners expect the output to be:
File Content
Program Finished
However, the actual output is:
Program Finished
File Content
This behavior surprises many developers when they first learn Node.js.
The reason behind this behavior is that fs.readFile() is asynchronous.
When Node.js encounters this function, it does not stop and wait for the file to be read. Instead, it delegates the file reading operation to the operating system or libuv and immediately continues executing the remaining code.
The callback function is stored temporarily.
When the file reading operation completes, Node.js places the callback into the callback queue. The Event Loop eventually executes the callback and prints the file content.
The flow can be understood using the following diagram.
Start Program
↓
Call fs.readFile()
↓
Delegate file reading
↓
Continue executing code
↓
Print "Program Finished"
↓
File reading completes
↓
Execute callback
↓
Print file content
Callbacks allowed Node.js to perform asynchronous operations without blocking the main thread.
For simple programs, callbacks are easy to understand and easy to use.
However, larger applications introduced a new challenge.
Problems with Nested Callbacks
Imagine that the application needs to perform several asynchronous operations in sequence.
The application must:
Read a file.
Parse the file content.
Save processed data to a database.
Send an email notification.
Using callbacks, the code may look like this:
readFile("data.txt", (err, data) => {
parseData(data, (err, parsedData) => {
saveData(parsedData, (err, result) => {
sendEmail(result, (err) => {
console.log("Process Completed");
});
});
});
});
Although this code works, it quickly becomes difficult to read.
Every new asynchronous operation increases the indentation level.
Error handling becomes repetitive.
The flow of execution becomes difficult to understand.
Developers often refer to this situation as Callback Hell because the code becomes deeply nested and difficult to maintain.
Callback Hell introduces several problems:
Code readability decreases.
Error handling becomes complicated.
Reusing code becomes difficult.
Understanding the execution flow requires more effort.
As JavaScript applications became larger and more complex, developers started looking for a better solution.
That solution was Promises.
Promise-Based Async Handling
A Promise represents a value that may become available in the future.
Instead of passing callbacks everywhere, developers can create a Promise and attach handlers that execute when the operation either succeeds or fails.
Every Promise exists in one of three states:
Pending
The asynchronous operation is still running.
Fulfilled
The operation completed successfully, and the result is available.
Rejected
The operation failed, and an error is available.
The lifecycle of a Promise can be represented like this:
Promise
│
Pending
/ \
Fulfilled Rejected
│ │
then() catch()
A Promise always starts in the Pending state.
After the operation completes, it becomes either Fulfilled or Rejected.
The corresponding handler is then executed.
Reading a File Using Promises
Node.js provides Promise-based APIs that make asynchronous code easier to read.
The same file-reading example can be written as:
const fs = require("fs").promises;
fs.readFile("notes.txt", "utf-8")
.then((data) => {
console.log(data);
})
.catch((err) => {
console.log(err);
});
This code separates the success and failure cases clearly.
The .then() method executes when the Promise is fulfilled.
The .catch() method executes when the Promise is rejected.
The flow becomes easier to understand because the code is not deeply nested.
Developers can read the code from top to bottom and understand what happens when the operation succeeds or fails.
Callback vs Promise
Callbacks and Promises both solve the same problem.
Both allow Node.js to execute code after an asynchronous operation completes.
However, Promises provide a cleaner and more structured approach.
Callback
readFile(file, (err, data) => {
if (err) {
console.log(err);
return;
}
console.log(data);
});
Promise
readFile(file)
.then((data) => {
console.log(data);
})
.catch((err) => {
console.log(err);
});
The Promise version is easier to read because the success path and the error path are clearly separated.
Promises also make it easier to chain multiple asynchronous operations without creating deeply nested code.
Benefits of Promises
Promises became popular because they solved many practical problems faced by developers.
Better Readability
Promise chains are easier to understand than nested callbacks.
Developers can follow the execution flow more naturally.
Centralized Error Handling
Errors can be handled in one place using .catch().
This reduces repetitive error handling code.
Easier Chaining
Multiple asynchronous operations can be connected together without increasing indentation levels.
Foundation for Async/Await
Modern JavaScript introduced async/await, which is built on top of Promises.
Developers who understand Promises usually find async/await much easier to learn.
Conclusion
Asynchronous programming exists because Node.js does not want to waste time waiting for slow operations.
Callbacks were the first widely used solution for handling asynchronous operations, and they enabled Node.js applications to remain responsive. However, deeply nested callbacks often reduced readability and made applications difficult to maintain.
Promises improved this situation by introducing a cleaner and more structured way to manage asynchronous code. They simplified error handling, improved readability, and became the foundation for modern features such as async/await.
Understanding the evolution from callbacks to Promises is important because it explains how modern Node.js applications are written and why asynchronous programming is one of the most powerful features of the Node.js ecosystem.
#javascript #nodejs #async #callbacks #promises #backend #webdevelopment #hashnode



