VS Vikesh Singh

Courses JavaScript Lesson 11

JAVASCRIPT · LESSON 11 OF 18

Asynchronous JavaScript: Callbacks to Promises

· 4 min read

Earlier lessons mentioned that JavaScript is single-threaded and that long operations freeze the page.

Earlier lessons mentioned that JavaScript is single-threaded and that long operations freeze the page. This lesson explains exactly how JavaScript gets around that limitation: asynchronous programming. It's the foundation for everything that talks to a server, a timer, or anything that takes time to finish.

Why JavaScript Needs Asynchronous Code

Some operations — reading a file, waiting for a timer, fetching data from a server — don't finish instantly. If JavaScript simply paused and waited for each one, the whole page would freeze while it waits, since JavaScript can only do one thing at a time on its main thread (lesson 1). Instead, JavaScript hands slow operations off to the browser itself, keeps running other code in the meantime, and gets notified when the slow thing finishes. setTimeout, which you saw briefly in the last lesson, is the simplest example.

console.log("Step 1");

setTimeout(function () {
  console.log("Step 3, after the delay");
}, 1000);

console.log("Step 2");

This prints "Step 1", then "Step 2", then — after about a one-second pause — "Step 3". Notice the order: setTimeout doesn't pause anything. It schedules the function to run later and immediately lets the rest of the code continue. This is the single most important fact about asynchronous JavaScript: code that's "waiting" does not block code that comes after it.

Callbacks And Callback Hell

The oldest pattern for handling "do this when the slow thing finishes" is the callback — simply a function passed into another function, to be called later.

function fetchUserData(callback) {
  setTimeout(function () {
    callback({ name: "Tony Stark", alias: "Iron Man" });
  }, 1000);
}

fetchUserData(function (user) {
  console.log("Got user:", user.name);
});

This works. The problem shows up once you need several async steps in sequence — say, fetch a user, then fetch that user's posts, then fetch that post's comments. Each step needs a callback nested inside the previous one's callback, producing deeply nested code often called "callback hell."

fetchUserData(function (user) {
  fetchPosts(user, function (posts) {
    fetchComments(posts[0], function (comments) {
      console.log("Finally got comments:", comments);
    });
  });
});

Every level of nesting adds indentation and makes error handling awkward — you'd need to check for errors at every single level separately. This real, painful problem is exactly why Promises were added to the language.

Introducing Promises

A Promise is an object representing a value that isn't ready yet, but will be — either successfully (resolved) or unsuccessfully (rejected) — at some point in the future.

function fetchUserData() {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      const success = true;
      if (success) {
        resolve({ name: "Vikesh Singh", alias: "Author" });
      } else {
        reject("Failed to fetch user");
      }
    }, 1000);
  });
}

fetchUserData()
  .then(function (user) {
    console.log("Got user:", user.name);
  })
  .catch(function (error) {
    console.log("Error:", error);
  });

Instead of passing a callback in, fetchUserData now returns a Promise immediately. .then() runs when the promise resolves successfully; .catch() runs if it rejects. The real win shows up when chaining several async steps, because each .then() can return another promise, flattening what would have been deep nesting into a flat, readable chain.

fetchUserData()
  .then(function (user) {
    return fetchPosts(user);
  })
  .then(function (posts) {
    return fetchComments(posts[0]);
  })
  .then(function (comments) {
    console.log("Comments:", comments);
  })
  .catch(function (error) {
    console.log("Something failed:", error);
  });

Now there's exactly one .catch() at the end, which catches an error from any step in the chain — a real, practical improvement over checking for errors separately inside every nested callback.

A Common Promise Chaining Mistake

A common beginner mistake: forgetting to return the inner promise from a .then() callback. Without that return, the next .then() in the chain fires immediately with undefined, instead of waiting for the inner operation to actually finish — a subtle bug that's easy to miss because your code doesn't crash, it just silently runs out of order.

SetTimeout Timing Is Not Exact

It's also worth knowing that setTimeout's delay argument is a minimum, not a guarantee. Because JavaScript is single-threaded, if the main thread is busy running other code when your 1000 millisecond timer is due, the scheduled function has to wait until the thread is free, so it might actually run at 1050ms or later. For almost everything you'll build, this tiny imprecision doesn't matter, but it's an important mental correction from thinking of setTimeout as a precise alarm clock — it's closer to "run this at the next available moment, no earlier than this delay."

A final practical habit: when reading someone else's asynchronous code, trace through it by writing out the order things will actually run in, rather than assuming top-to-bottom order the way synchronous code reads. This deliberate tracing — what runs immediately, what gets scheduled for later, and in what order the scheduled pieces resolve — is the core skill that makes asynchronous JavaScript feel predictable instead of mysterious, and it only gets easier with practice.

Put this into practice: write a function that returns a Promise which resolves after a one-second setTimeout with a message of your choice, then chain two .then() calls after it — the first logging the message, the second logging a follow-up message. Promises are genuinely the backbone of real JavaScript, so this pattern is worth typing out by hand rather than just reading.