Skip to main content

Command Palette

Search for a command to run...

Async Code in Node.js: Callbacks and Promises

Updated
5 min read
Async Code in Node.js: Callbacks and Promises

One of the biggest strengths of Node.js is its ability to handle asynchronous operations efficiently. Unlike traditional blocking systems where one task waits for another to finish, Node.js is designed to execute tasks without stopping the entire application.

This is especially important because many operations in backend development take time. Reading files, querying databases, calling APIs, and handling network requests are not instant. If JavaScript waited for each operation to complete before moving on, applications would become slow and inefficient.

This is why asynchronous code exists in Node.js.

Understanding callbacks and promises is essential because they form the foundation of modern asynchronous programming in JavaScript.

What Is Synchronous vs Asynchronous Code

Synchronous code runs line by line.

console.log("Start");
console.log("Middle");
console.log("End");

Output:

Start
Middle
End

Each statement waits for the previous one.

Asynchronous code behaves differently.

console.log("Start");

setTimeout(() => {
 console.log("Delayed");
},2000);

console.log("End");

Output:

Start
End
Delayed

The delayed task does not block execution.

That is asynchronous behavior.

Why Async Code Exists in Node.js

Node.js uses a single-threaded event loop.

That means one main thread handles many tasks.

Without asynchronous behavior, operations like database calls would block everything.

Imagine:

const data = readHugeFile();
console.log(data);

If file reading takes five seconds and blocks execution, every user request would wait.

Bad for performance.

Instead Node.js handles such tasks asynchronously.

Example:

const fs = require("fs");

fs.readFile("data.txt","utf8",(err,data)=>{
 console.log(data);
});

console.log("Program continues...");

Output may be:

Program continues...
File contents here

The file is read in background while code keeps running.

This makes Node.js highly scalable.

Why async code is needed:

Non-blocking execution

High concurrency

Better performance

Handles many users efficiently

Ideal for I/O-heavy applications

This is one reason Netflix, PayPal, and many large systems use Node.js.

The Event Loop Idea

At the heart of Node.js async behavior is the event loop.

Simple idea:

Main thread keeps running

Long tasks move to background

When finished, callback runs

Flow:

Task starts
Task goes to background
Program continues
Task finishes
Callback executes

This creates non-blocking behavior.

Callback-Based Async Execution

Before promises, callbacks were the main async pattern.

A callback is simply a function passed into another function to run later.

Basic example:

function greet(name,callback){
 console.log("Hello " + name);
 callback();
}

greet("Mahesh", function(){
 console.log("Done");
});

Output:

Hello Mahesh
Done

In async programming:

setTimeout(()=>{
 console.log("Task finished");
},2000);

The function passed to setTimeout is a callback.

File Reading with Callbacks

Classic Node.js example:

const fs = require("fs");

fs.readFile("data.txt","utf8",(err,data)=>{
 if(err){
   console.log(err);
   return;
 }

 console.log(data);
});

Callback receives:

Error

Data

Very common pattern:

(error,result)=>{}

Called error-first callback pattern.

Callback-Based Async Flow

Suppose we fetch user:

function getUser(callback){
 setTimeout(()=>{
   callback({
     id:1,
     name:"Mahesh"
   });
 },1000);
}

Using it:

getUser((user)=>{
 console.log(user);
});

Output:

{
 id:1,
 name:"Mahesh"
}

Works fine.

But problems appear with multiple async steps.

Problems With Nested Callbacks

Suppose:

Get user

Get posts

Get comments

Using callbacks:

getUser(function(user){

 getPosts(user.id,function(posts){

   getComments(posts[0].id,function(comments){

      console.log(comments);

   });

 });

});

This creates deep nesting.

Looks like a pyramid.

Often called:

Callback Hell

or

Pyramid of Doom

Problems:

Hard to read

Hard to debug

Hard to maintain

Error handling messy

Deep nesting grows quickly

Visual:

step1(function(){

 step2(function(){

   step3(function(){

      step4(function(){

      });

   });

 });

});

Gets ugly fast.

Real-world code used to become massive.

That motivated promises.

Callback Hell Example

Imagine login flow:

authenticate(function(user){

 getProfile(user,function(profile){

   getOrders(profile,function(orders){

      processOrders(orders,function(result){

         console.log(result);

      });

   });

 });

});

Too much nesting.

Very hard to scale.

Promise-Based Async Handling

Promises were introduced to solve these problems.

A Promise represents a value available:

Now

Later

Or never if rejected

States:

Pending

Fulfilled

Rejected

Basic syntax:

const promise = new Promise(
 (resolve,reject)=>{

   let success=true;

   if(success){
      resolve("Done");
   }else{
      reject("Error");
   }

 }
);

Consume promise:

promise
.then(result=>{
 console.log(result);
})
.catch(error=>{
 console.log(error);
});

Much cleaner.

Understanding resolve and reject

Resolve:

Success result.

Reject:

Failure result.

Example:

const fetchData =
new Promise((resolve,reject)=>{

 setTimeout(()=>{
   resolve("Data loaded");
 },2000);

});

Use it:

fetchData
.then(data=>{
 console.log(data);
});

Output:

Data loaded

Rewriting Callbacks Using Promises

Callback version:

getUser(function(user){
 getPosts(user.id,function(posts){
   console.log(posts);
 });
});

Promise version:

getUser()
.then(user=>{
 return getPosts(user.id);
})
.then(posts=>{
 console.log(posts);
})
.catch(error=>{
 console.log(error);
});

Huge improvement.

No pyramid.

Readable flow.

Promise Chaining

One major benefit:

Chain async operations.

login()
.then(user=>{
 return getProfile(user);
})
.then(profile=>{
 return getOrders(profile);
})
.then(orders=>{
 console.log(orders);
})
.catch(err=>{
 console.log(err);
});

Reads top to bottom.

Much cleaner than nested callbacks.

Benefits of Promises

Promises solved major callback problems.

Better Readability

Callback hell:

a(()=>{
 b(()=>{
   c(()=>{
   });
 });
});

Promise chain:

a()
.then(b)
.then(c)

Clearly better.

Easier Error Handling

Callbacks often need error checks everywhere.

if(err){
 return callback(err);
}

Repeated repeatedly.

Promises:

.catch(err=>{
 console.log(err);
});

Centralized handling.

Cleaner.

Better Maintainability

Promise chains scale better.

Large applications benefit heavily.

Easy to add steps:

task1()
.then(task2)
.then(task3)
.then(task4)

Simple.

Avoid Callback Hell

Biggest reason promises became popular.

They flatten nested async code.

Composable Async Logic

Promises can run in parallel.

Promise.all()

Promise.all([
 fetchUsers(),
 fetchPosts(),
 fetchComments()
])
.then(data=>{
 console.log(data);
});

Runs together.

Very powerful.

Promise Example with API Style Logic

function fetchUser(){

 return new Promise(resolve=>{
   setTimeout(()=>{
      resolve("User Loaded");
   },1000);
 });

}

Use:

fetchUser()
.then(data=>{
 console.log(data);
});

Simple and elegant.