Skip to main content

Command Palette

Search for a command to run...

Understanding Async JavaScript in Simple Way: Callbacks, Promises & Async/Await

Updated
5 min readView as Markdown
Understanding Async JavaScript in Simple Way: Callbacks, Promises & Async/Await

In modern web development, asynchronous programming is very important. JavaScript is single threaded, so normally code runs line by line. If one heavy task comes, whole application can stop for some time and feel slow. Async programming solves this problem by running heavy tasks in background without blocking other code.

Things like:

  • API calls

  • Database queries

  • File reading

  • Timers

all use asynchronous behavior.

Over time JavaScript improved many ways to handle async code. First callbacks, then promises, and now async/await.

Why Async Code Exists in Node.js

Node.js handles many users and requests together. If everything worked synchronously, one slow task could block all other users.

For example:

  • User requests data

  • Server reads a big file

  • Another user sends request

If Node.js waits for first task to finish, second user must also wait.

This is why async code exists.

Node.js sends heavy operations in background and continues running other code. This makes apps faster and scalable.

Starting with File Reading Example

Let us understand with simple example.

const fs = require("fs");

console.log("Start");

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

  console.log(data);
});

console.log("End");

Output

Start
End
File Content Here

Many beginners get confused here.

Why "End" printed before file content?

Because readFile() works asynchronously.

Node.js starts reading file in background and moves to next line immediately.

Callback-Based Async Execution

Callbacks are oldest and basic way to handle asynchronous code in JavaScript.

A callback is simply a function passed into another function which runs later after task completes.

In above example:

(err, data) => {
   console.log(data);
}

This callback runs only after file reading finishes.

Callback Flow Step-by-Step

Step 1

JavaScript executes:

console.log("Start");

Output:

Start

Step 2

fs.readFile() starts reading file asynchronously.

Node.js sends this work in background and does not wait.

Step 3

Next line executes immediately:

console.log("End");

Output:

End

Step 4

When file reading completes, callback function executes.

console.log(data);

Output:

File Content Here

Diagram Idea: Callback Execution Chain

Start Execution
      |
      v
console.log("Start")
      |
      v
fs.readFile() starts
      |
      |---- File reads in background
      |
      v
console.log("End")
      |
      v
Callback Queue
      |
      v
Callback Executes

Problems with Nested Callbacks

Callbacks are okay for small programs. But when many async tasks depend on each other, code becomes messy.

Example:

getUser(userId, (user) => {
  getPosts(user.id, (posts) => {
    getComments(posts[0].id, (comments) => {
      console.log(comments);
    });
  });
});

This structure is called:

  • Callback Hell

  • Pyramid of Doom

because code keeps going deeper and deeper.

Why Nested Callbacks Are Bad

Hard to Read

Code becomes confusing very quickly.

Error Handling is Difficult

Every callback may need separate error handling.

Hard to Maintain

Adding new logic becomes risky.

Debugging is Painful

Finding bugs inside nested callbacks is not easy.

Promise-Based Async Handling

Promises were introduced to solve callback hell problem.

A promise represents future completion or failure of an async operation.

Instead of nesting, promises allow chaining.

Example:

readFilePromise("data.txt")
  .then((data) => {
    console.log(data);
  })
  .catch((err) => {
    console.log(err);
  });

This looks cleaner and easier to understand.

Promise Lifecycle Flow

A promise has mainly 3 states:

           Promise Created
                  |
          -----------------
          |               |
       Fulfilled       Rejected
          |               |
       .then()         .catch()

Benefits of Promises

Better Readability

Promises make code flatter and cleaner.

Centralized Error Handling

Instead of handling errors everywhere, one .catch() can handle many errors.

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

Easy Chaining

fetchUser()
  .then(fetchPosts)
  .then(fetchComments)
  .then(showData)
  .catch(handleError);

Flow becomes more organized.

Callback vs Promise Readability

Callback Style

login(user, () => {
  getProfile(() => {
    getPosts(() => {
      console.log("Done");
    });
  });
});

Promise Style

login(user)
  .then(getProfile)
  .then(getPosts)
  .then(() => {
    console.log("Done");
  });

Promise version is much easier to read.

Async/Await: Modern Standard

Later JavaScript introduced async/await.

It is built on top of promises and makes async code look synchronous.

Example:

async function loadData() {
  try {
    const data = await readFilePromise("data.txt");
    console.log(data);
  } catch (err) {
    console.log(err);
  }
}

This feels simpler and cleaner.

Why Developers Like Async/Await

Code Looks Clean

Execution flow is easier to follow.

Easier Error Handling

Using try/catch feels more natural.

Easier Debugging

Code becomes easier to debug compared to nested callbacks.

Understanding Event Loop

Behind all async behavior, Node.js uses something called Event Loop.

Event loop handles async tasks and decides when callbacks or promises should run.

Without event loop, asynchronous JavaScript would not work.

Best Practices

Prefer Async/Await

Modern apps mostly use promises and async/await instead of callbacks.

Always Handle Errors

try {
   const data = await fetchData();
} catch (err) {
   console.log(err);
}

Run Independent Tasks Together

Instead of:

await task1();
await task2();

Use:

await Promise.all([task1(), task2()]);

This improves performance.

Keep Functions Small

Small functions are easier to read and test.

Conclusion

At first, asynchronous JavaScript feels confusing for almost everyone. Things like callbacks, promises, event loop, async/await all look complicated in starting. But once you understand how JavaScript handles tasks in background, everything starts making sense.

Callbacks were the old way to handle async tasks, but too many nested callbacks made code messy. Then promises came and made code cleaner. After that, async/await made async code feel almost like normal synchronous code.

Today most developers use promises and async/await because code becomes easier to read, easier to debug, and easier to maintain.

So if you are learning Node.js or JavaScript, understanding asynchronous programming is super important because almost every real-world application depends on it.