The Difference Between Callbacks And Promises

Why are promises better than callbacks?

Hint: It’s not about callback hell (pyramid of doom)!


The difference between callbacks and promises in JavaScript is subtle but significant! Why exactly are we ditching callbacks in favor of promises?

How would you answere these questions in an interview?

The superiority of promises over callbacks is all about trust and control. Let me explain.

We generally need to use callbacks (or promises) when there is a slow process (that’s usually IO-related) that we need to perform without blocking the main program process. I once compared giving an asynchronous worker a callback function to giving a barista in a coffee shop your name to have it called when your order is ready. While this analogy captures the essence of working with an evented resource, it’s limited when it comes to understanding the problem of callbacks (which is not about their nesting nature).

Let’s try a different analogy. Let’s say you want to cook some rice and plain yogurt using a stove. Yes. You heard that right. You can cook plain yogurt and it’s extremely good when done right.

1*h9Ua ELO7bv9pvA9Hvv89A

The problem is that cooking yogurt requires continuous stirring. If you stop stirring the yogurt will burn. This means that while you’re stirring the yogurt you’re blocked from doing anything else.

Moreover, when the yogurt starts boiling the recipe at that point calls for lowering the heat, adding meat broth, and then stirring some more.

If you’re the only one cooking you’ll need to do the yogurt stirring task synchronously! Your body, which is comparable to the single JavaScript thread in this analogy, is blocked for the duration of this synchronous task. You’ll have to finish the yogurt cooking before you can start on the rice. You can compare this to doing a loop in JavaScript:

putYogurtInPot()
putPotOnStove();

while (yogurtIsNotBoiling) {
  stirYogurt();
  // Now you're blocked until the loop is done
}

lowerHeat();
addMeatBroth(yogurt)

while (yogurtIsNotBoiling) {
  stirYogurt();
  // You're blocked again until the loop is done
}

turnStoveOff();

// Start on the rice

If you need to cook both the yogurt and rice simultaneously then you need to get some help. You need to delegate! You need another "thread". You need another person.

Your son is in the house and he happens to be free to help out. Great. You call him up and ask him to do the stirring for you.

Having someone else do the stirring here is like having an external module (like Node’s fs) do the slow IO work for you. Your son in this analogy is the Node module itself.

However, to work with an async resource (with Node’s fs module methods for example) you need to use callbacks (or promises as we’ll see later). The same goes for your son. You need to give him instructions (along with the raw yogurt and meat broth). He might know how to stir but you need to tell him what to do with everything (and when to do it). You basically give him a callback of instructions and he is expected to execute these instructions at a certain point.

son.handleYogurtStirring(
  rawYogurt,
  (problem?, boilingYogurt) => {
    if (there is a problem) {
      reportIt();
    } else {
      lowerHeat();
      const yogurtMeatMix =
        addMeatBroth(boilingYogurt);
      handleYogurtStirring(yogurtMeatMix);
    }
  }
);

// Start on the rice

By doing that, you free your single-threaded body to do something else. You can cook the rice now. You are using an asynchronous API.

So what is the problem? This is all good, isn’t it?

The problem with callbacks is that you lose control of what happens to the yogurt.

Not only is the stirring process itself now controlled by your helper, but the tasks that need to be done when the yogurt gets to a boiling point are also controlled by him. There is no guarantee that he will actually perform your instructions exactly like you described them.

  • Do you trust that he’ll correctly identify the boiling point?

  • Do you trust that he’ll remember to put meat broth? Do you trust he’ll put enough and not overdo it?

  • Do you trust that he’ll remember to lower the heat?

This lack of trust is one reason why we need promises in our lives. It is why I would simply make my son "promise" to watch for the boiling point, lower the heat, and add the meat broth. I’ll also maybe make him repeat the instructions. With his verbal assurance, the yogurt cooking process becomes a promise-based one.

(async () => {
  try {
    const boilingYogurt =
      await son.handleYogurtStirringP(rawYogurt);
    son.lowerHeat();
    const yogurtMeatMix =
      son.addMeatBroth(boilingYogurt);
    const cookedYogurt =
      await son.handleYogurtStirringP(yogurtMeatMix);
  } catch(problem) {
    son.reportIt();
  }
})();

// Start on the rice

The only difference between handleYogurtStirring and this new handleYogurtStirringP is that I was promised an outcome for handleYogurtStirringP. My helper verbally assured me he will follow instructions. I have a little bit of trust added to the equation.

I used the async/await syntax to consume promises here but this is not really about async/await vs then/catch. However, you should favor the async/await syntax because it has a better flow that matches the way we analyze programs. Without async/await you would need to use function nesting to accomplish some tasks. Some people even call this promise hell!

Trust is great but we still do not have control. You can get some control by changing the nature of your instructions and having your son promise to notify you when the yogurt boils the first time and then you can add the meat broth to it yourself. This gives you better control but it also means that you need to be able to respond when notified, pause what you’re doing to handle the meat-broth task.

(async () => {
  try {
    const boilingYogurt =
      await son.handleYogurtStirring2(rawYogurt);
    you.lowerHeat();
    const yogurtMeatMix =
      you.addMeatBroth(boilingYogurt);
    const cookedYogurt =
      await son.handleYogurtStirring2(yogurtMeatMix);
  } catch(problem) {
    you: inspect(problem)
         && maybe(retry)
         || orderSomeTakeout();
  }
})();

// Start on the rice

The level of trust and control you get from promises depend on the library that you use. For example, let’s say you have a fancy electric cooker with a built-in stirring arm. You put raw yogurt in and you get cooked yogurt out. You can program the cooker to cook the yogurt for exactly 13.5 minutes (or whatever time is needed), and you can program it to sound an alarm if the built-in stirring arm is jammed. This cooker’s "API" is also a promise-based one because you have trust that it will either finish the process successfully or sound an alarm if something goes wrong. You have a lot of trust here!

(async() => {
  try {
    const cookedYogurt =
      await cooker.handleYogurtCooking(rawYogurt);
  } catch(problem) {
    you: inspect(problem)
         && maybe(retry)
         || orderSomeTakeout();
  }
})();

// Start on the rice

Not only that, but you also have a lot more control over this cooker. You can make sure it’s on a steady non-slip surface and that kids don’t mess with it. You can even plug it into some form of uninterruptible power supply. You have an actual promise object in this analogy. You can do things to it while it’s pending. The yogurt cooked with a cooker might not be as tasty as the one cooked on the stove but it’s certainly a more reliable outcome.

That’s really the difference between callbacks and promises. It’s not about syntax or nesting. It’s about control and trust.

Want to read more analogies like this? I’ve got a lot more.