npm basics for beginners

Async JavaScript Explained: Callbacks, Promises, and Async/Await

Much of JavaScript involves waiting: for data from a server, a file to load, or a timer to finish. If JavaScript stopped everything while it waited, pages would freeze and servers would stall. Instead, it handles slow tasks asynchronously — it starts a task, carries on with other work, and deals with the result when ready. Three tools have shaped this: callbacks, promises, and async/await. They build on each other, and understanding all three helps you read both old and new code.

Async JavaScript explained: callbacks, promises, and async/await

Synchronous vs Asynchronous Code

Synchronous code runs line by line, in order. Each line finishes before the next one starts. Most beginner code works this way, and it is easy to follow.

Asynchronous code, by contrast, starts a task that takes time and does not block the rest of the program. Consider this example:

console.log("First");
setTimeout(() => {
  console.log("Second");
}, 1000);
console.log("Third");

The output is “First”, “Third”, then “Second”: the timer does not pause the program, it schedules its function to run later. JavaScript runs on a single thread, executing one piece of code at a time, but slow operations run in the background and their results are queued until the main thread is free. The basic idea — start, continue, come back later — is essential.

Callbacks: The Original Approach

A callback is simply a function passed to another function, to be called later when a task finishes. Here is a simple example:

function greetLater(name, callback) {
  setTimeout(() => {
    callback(`Hello, ${name}!`);
  }, 500);
}

greetLater("Ayesha", (message) => {
  console.log(message);
});

Callbacks are fine for a single task, but when tasks depend on each other the code nests deeper and deeper:

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

This nesting is called “callback hell”. It is hard to read and debug, and each level needs its own error check. Callbacks are still used in older Node.js APIs and event handlers, so you should be able to read them, but for several dependent steps there is a better way.

Promises: A Cleaner Way to Handle Results

A promise is an object that represents a value that will be available in the future. It can be in one of three states: pending, fulfilled (success), or rejected (failure). Instead of nesting callbacks, you attach handlers with .then() for success and .catch() for errors.

Here is a promise-based version of a delayed greeting:

function greetLater(name) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(`Hello, ${name}!`);
    }, 500);
  });
}

greetLater("Bilal")
  .then((message) => {
    console.log(message);
  })
  .catch((error) => {
    console.error(error);
  });

resolve fulfils the promise with a value that .then() receives; if something goes wrong, reject is called and .catch() handles it. Errors can thus be handled once, at the end of the chain, rather than at every step.

Promises also chain neatly. Each .then() can return a new value or another promise, and the next .then() receives it:

fetchUser(1)
  .then((user) => fetchPosts(user.id))
  .then((posts) => fetchComments(posts[0].id))
  .then((comments) => {
    console.log(comments);
  })
  .catch((error) => {
    console.error("Something went wrong:", error);
  });

This flat chain is much easier to read, and one .catch() covers errors from any step. Promise.all runs several independent promises at the same time and waits for all of them:

Promise.all([fetchUser(1), fetchSettings()])
  .then(([user, settings]) => {
    console.log(user, settings);
  })
  .catch((error) => {
    console.error(error);
  });

Use Promise.all when tasks are independent, since parallel is faster than sequential — but note that if any promise rejects, the whole Promise.all rejects.

Async/Await: Asynchronous Code That Reads Like Normal Code

Async/await is syntax built on promises that makes working with them feel like ordinary synchronous code. A function marked async always returns a promise, and inside it await pauses that function until a promise settles, without blocking the rest of the program.

Here is the same chain rewritten with async/await:

async function showComments() {
  try {
    const user = await fetchUser(1);
    const posts = await fetchPosts(user.id);
    const comments = await fetchComments(posts[0].id);
    console.log(comments);
  } catch (error) {
    console.error("Something went wrong:", error);
  }
}

showComments();

This reads top to bottom like synchronous code, with familiar try/catch error handling.

Remember that await only works inside an async function (or at module top level in modern environments), and avoid awaiting independent tasks one by one when they could run in parallel:

// Slower: waits for user, then waits for settings
const user = await fetchUser(1);
const settings = await fetchSettings();

// Faster: starts both, then waits for both
const [user2, settings2] = await Promise.all([
  fetchUser(1),
  fetchSettings(),
]);

Both are correct, but the second is faster for independent tasks.

Which One Should You Use?

For new code, async/await is usually clearest, especially for dependent steps. Promises remain essential, since async/await is built on them and APIs return promises directly. Callbacks are worth recognising for older code and event listeners, but you rarely need deep nesting today. In practice you will mix all three — a callback for a click, a promise from fetch, async/await for a sequence — choosing the clearest tool for each situation.

Common Pitfalls

Always handle errors: an unhandled rejection can fail silently or crash a Node.js process, so add a .catch() or try/catch. Also remember to return a value or promise inside .then(), or the next step receives undefined. Finally, code after an await runs only after that promise settles; for cleanup that must happen regardless, such as hiding a loading indicator, use a finally block:

async function loadData() {
  try {
    const data = await fetchData();
    console.log(data);
  } catch (error) {
    console.error(error);
  } finally {
    console.log("Finished loading.");
  }
}

Conclusion

Asynchronous JavaScript exists because real programs must wait — for networks, files, timers, and input — without freezing. Callbacks came first, promises made chaining and error handling cleaner, and async/await made async code read like ordinary code. Learn them in that order, practise with small examples, and always handle errors.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *