DEV Community

Cover image for Understanding JavaScript Promises and Promise Chaining in ES6
Karson Kalt
Karson Kalt

Posted on

Understanding JavaScript Promises and Promise Chaining in ES6

At some point in your programming journey you're bound to run into the big confusing issue --- Promises. What are they and how do they work?

When I began learning about asynchronous programming, I found the concept of Promises in JavaScript, difficult to understand and confusing! Any Google search or YouTube video only seemed to add more confusion. I was bombarded by new words that didn't have much meaning to me, and videos that dug deep into the syntax of Promises, but nothing that ever slowed down enough to break down asynchronous programming to a beginner.

This article aims to break down the fundamentals of asynchronous programming in JS by:

  • Taking a look at synchronous programming and defining singly-threaded languages
  • Understanding the JS browser environment: Web-APIs, call stack, callback queue, and event loop
  • Learning to instantiate a new Promise and when its callbacks are invoked
  • Explaining the various states of a Promise
  • Taking a look at Promise chaining with .then and .catch.
  • Learning about Promise class functions like Promise.all, and Promise.race

How JavaScript Runs

Before we start learning about Promises, we first need to understand how JavaScript works. JavaScript is a singly-threaded, non-blocking language. Now you might be thinking, what does that even mean. Let's break it down.

As you think about the code you have written in JavaScript up to this point, we have typically assumed we only do one task at a time. Ignoring the concepts of compilation, optimization, and hoisting, our JavaScript files are read from the top-down. In fact, if we place a debugger in our code, we can physically click "step over" and watch as we move line-by-line through our code.

const arr = [1, 2, 3, 4, 5, 6];

for (const item in arr) {
  debugger;
  console.log(item);
}
Enter fullscreen mode Exit fullscreen mode

Stepping through a debugger

Singly-threaded

Being singly-threaded means that our code can only complete only one task at a time. This makes our code pretty easy to follow logically, and confidently know what will happen at run-time. Other languages like C#, Java, and Go are considered multi-threaded languages that share memory on the CPU to complete separate tasks.

What about that other word, non-blocking?

Non-blocking

Let's first examine this example. If JavaScript is singly-threaded, then we can think of our code as a line. The browser is the cashier and can only help one customer (line of code) at a time. Let's say we are shopping and someone in front of us is taking a really long time at the checkout –– they asked to talk to the manager and the manager has to come from the back of the store to talk to the customer.

If JavaScript wasn't non-blocking, then everyone behind this customer would have to wait, probably a few minutes, until the customer who wanted the manager has finished with their issue. The concept of being non-blocking means that JavaScript has the ability for customers who need to talk to the manager, to step aside and wait for the manager.

How can JavaScript do that if we only have one line?

Memory Heap and Call Stack

Let's start with the basics. What is a program anyway? A program:

  • Has to allocate memory
  • Has to parse and execute scripts (read and run commands)

In the browser, there is a JavaScript engine that turns JS into machine executable code. The engine has two parts, the memory heap and the call stack.

The memory heap is where memory allocation happens. We do this in our code with something like const a = 1, it's as simple as that. A memory leak is when we have unused memory just laying around, sucking up space of our program but never actually getting used. That's why global variables are bad, because they are just laying around in the global scope.

The call stack is the second part of our program. The call stack reads a line of code, and adds in the call stack. As the code finishes execution, it pops it off the top of the stack.

Let's take a look at the example below and walk through the call stack.

  • First first() gets added to the call stack (it begins running)
  • It doesn't finish running but then second() begins running, so second() is added.
  • We add the console.log, which is run and finishes and pops it off.
  • We then finish running second() so it is popped off.
  • We then finish first() so it it is popped off.
const first = () => {
  const second = () => {
    console.log("third");
  };
  second();
};

first();
// => "third"
Enter fullscreen mode Exit fullscreen mode

JavaScript Environment

Let's examine the 5 major parts of our JavaScript environment in the browser.

  1. Heap (Part of JavaScript)
  2. Call Stack (Part of JavaScript)
  3. Web API
  4. Task Queue/Microtask Queue
  5. Event Loop

When we run setTimeout, it is run in the browser, and told it to add it to the Web API. And it popped off the call stack. Once the setTimeout expires, it adds it to the callback queue.

The event loop checks all the time, is the Call Stack empty? If it is empty, then it asks the callback queue, "Do you have any callbacks?"

Whether you set the timeout to zero seconds or five minutes will make no difference—the console.log called by asynchronous code will execute after the synchronous top-level functions. This happens because the JavaScript host environment, in this case the browser, uses a concept called the event loop to handle concurrency, or parallel events. Since JavaScript can only execute one statement at a time, it needs the event loop to be informed of when to execute which specific statement. The event loop handles this with the concepts of a stack and a queue.

JavaScript Event Loop

As our code is run, each new object or is added to the heap (JS memory storage). Additionally, as we traverse into deeper callback functions, layers are added to the call stack until they are finished executing and popped from the stack (also managed by JavaScript).

The browser gives us additional functionality of our JavaScript runtime environment. When we runs into a Web-API (think localStorage, setTimeout(), fetch, location, etc), those actions are sent to the browser. When they are ready, those tasks are added to the the task queue. Tasks at the front of the queue wait to be picked up by the event loop. As our JS call stack is cleared, JavaScript checks the event loop for any new responses and executes that code.

Why is the JS runtime enviornment so complex?

As AJAX became increasingly popular in the early 2000s, JavaScript became more and more responsible for handling asynchronous actions. Libraries like jQuery attempted to solve some of the issues that modern JavaScript and browsers were facing. Eventually browsers added additional functionality themselves and a new version of JavaScript was released that allowed for asynchronous behavior.

So, What is a Promise?

With the introduction of ES6, Promises were introduced, letting the world avoid deeply nested callbacks aka the JavaScript pyramid of doom.

In the real world, what is a promise?

n. a declaration or assurance that one will do a particular thing or that a particular thing will happen.

In JavaScript, a Promise is an object that may produce a value at some point in the future.

Promise 101

A Promise has three possible states:

  • Pending: not yet fulfilled or rejected
  • Fulfilled: when a successful response is received
  • Rejected: when there is an error/not a successful response

When a Promise is created, it is instantiated with two functions as arguments –– one that is invoked on fulfilled status, and one that is invoked on rejected status. These callbacks provide the Promise with a payload of data, aka the response. Let's start by building our first promise.

Promise Executor

As a Promise is instantiated, it expects a callback function to be passed that accepts up to two callback functions. The first nested callback is invoked on a fulfilled status, and the second on rejected. To get started, let's take a look at a common executor function pattern.

function executor(resolutionFunc, rejectionFunc) {
  // Typically, some asynchronous operation goes here like a fetch call to a server.

  try {
    resolutionFunc(value);
    // The promise state is fulfilled and the promise result is value
  } catch {
    rejectionFunc(reason);
    // The promise state is rejected and the promise result is reason
  }
}
Enter fullscreen mode Exit fullscreen mode

Executor functions usually have some sort of conditional or error handling. In our example, we try to run resolutionFunc(), and if an error is thrown within the block, we invoke rejectionFunc().

Most likely, you have seen promises returned from a fetch call, however in this example we are going to use the setTimeout() Web-API and attempt to execute our resolution function after a specified set of time (100ms). Let's write a standalone executor function and invoke it.

function executor(resolutionFunction, rejectionFunction) {
  setTimeout(() => {
    try {
      resolutionFunction("finished");
    } catch {
      rejectionFunction("error");
    }
  }, 1000);
}

executor(
  (val) => console.log(val),
  (val) => console.log(val)
);
// finished
Enter fullscreen mode Exit fullscreen mode

Refactoring as Promise Creator Function

Let's refactor our executor function as an anonymous arrow function passed as we instantiate a new Promise. With this approach, we can call function makeFulfilledPromise(), and get back a new Promise who's status changes to fulfilled after 100ms.

NOTE: In the example below the curly braces are omitted from the arrow function, implicitly returning the Promise that was instantiated in the expression.

const makeFulfilledPromise = () =>
  new Promise((resolutionFunction, rejectionFunction) => {
    setTimeout(() => {
      try {
        resolutionFunction("finished");
      } catch {
        rejectionFunction("error");
      }
    }, 1000);
  });

makeFulfilledPromise();
// => Promise {<fulfilled>}
//      [[Prototype]]: Promise
//      [[PromiseState]]: "fulfilled"
//      [[PromiseResult]]: "finished"
Enter fullscreen mode Exit fullscreen mode

If we throw an error in our try, the catch block executes and invokes rejectionFunction(), passing the returned Promise a result of "error".

const makeRejectedPromise = () =>
  new Promise((resolutionFunction, rejectionFunction) => {
    setTimeout(() => {
      try {
        throw new Error("something went wrong");
        resolutionFunction("finished");
      } catch {
        rejectionFunction("error");
      }
    }, 1000);
  });

makeRejectedPromise();
// Uncaught (in promise) error
// => Promise {<rejected>: 'error'}
//      [[Prototype]]: Promise
//      [[PromiseState]]: "rejected"
//      [[PromiseResult]]: "error"
Enter fullscreen mode Exit fullscreen mode

Let's combine these two functions by passing a few arguments to our function -- allowing us to dynamically create a Promise with different attributes. As we start playing with Promises in the console, I am going to define a few constants that we can reference throughout this article.

const makePromise = (response, delay, success) =>
  new Promise((resolve, reject) => {
    setTimeout(() => {
      if (success) {
        resolve(response);
      } else {
        reject("error");
      }
    }, delay);
  });

makePromise("success", 3000, true);
// => Promise {<fulfilled>}
//      [[Prototype]]: Promise
//      [[PromiseState]]: "fulfilled"
//      [[PromiseResult]]: "success"

const a = () => makePromise("A finished", 3000, true);
const b = () => makePromise("B finished", 5000, true);
const c = () => makePromise("C finished", 8000, true);

const z = () => makePromise("Z finished", 2000, false);
Enter fullscreen mode Exit fullscreen mode

Promise Chaining with .then and .catch

Both .then and .catch return a new Promise object. Both of these methods expect similar arguments of callbacks as the function we passed when instantiating a new Promise. Like before, a successful response callback is invoked if new Promise is successful, while the second argument is invoked if not successful. Most often, you will see a .then only passing a successful response callback, and a .catch at the very end of the chain.

.catch will run if an error is thrown anywhere in the Promise chain, and can be thought of as essentially syntactic sugar for .then(null, function).

The result of the previously chained promised will be passed as an argument of the callback function on a successful response, but not assigned to the result of the new Promise.

Let's see it in action.

const aThen = a().then((result) => {
  result = `The result of the previous promise was: ${result}`;
  console.log(result);
});

aThen;
// => Promise {<fulfilled>}
//      [[Prototype]]: Promise
//      [[PromiseState]]: "fulfilled"
//      [[PromiseResult]]: undefined
// The result of the previous promise was: A finished
Enter fullscreen mode Exit fullscreen mode

If we wanted to give the returned Promise a result, we can call return inside of the .then callback.

const aThen = a().then((result) => {
  result = `The result of the previous promise was: ${result}`;
  console.log(result);
  return "aThen finished";
});

aThen;
// => Promise {<fulfilled>}
//      [[Prototype]]: Promise
//      [[PromiseState]]: "fulfilled"
//      [[PromiseResult]]: "aThen finished"
// The result of the previous promise was: A finished
Enter fullscreen mode Exit fullscreen mode

Chaining .then on a rejected Promise will not invoke the successful callback.

const zThen = z().then((result) => {
  result = `The result of the previous promise was: ${result}`;
  console.log(result);
  return "zThen finished";
});

zThen;
// Uncaught (in promise) Error
// => Promise {<rejected>: 'error'}
//      [[Prototype]]: Promise
//      [[PromiseState]]: "rejected"
//      [[PromiseResult]]: "error"
Enter fullscreen mode Exit fullscreen mode

Remember .catch is just a .then invoked if the previous Promise was rejected. Since .catch and .then return a new promise, If we return from the callback, the returned promise is successful. If no value is returned, then the previous chained Promise is returned.

const zThen = z()
  .then((result) => {
    result = `The result of the previous promise was: ${result}`;
    console.log(result);
    return "zThen finished";
  })
  .catch((result) => {
    console.log(result);
    return "zThen error";
  });

zThen;
// Uncaught (in promise) Error
// => Promise {<fulfilled>: 'zThen error'}
//      [[Prototype]]: Promise
//      [[PromiseState]]: "fulfilled"
//      [[PromiseResult]]: "zThen error"
Enter fullscreen mode Exit fullscreen mode

Promise Class Functions

Now that we have a good understanding of Promises, .then, and .catch, let's try some simple code challenges using our a(), b(), and c() Promise creator functions defined above.

  1. Create a function that creates all Promises at the same time, console.log the Promise responses.
  2. Create a function that sequentially creates each Promise, creating one after the next. console.log when each promise is finished.
  3. Create a function that creates all Promises at the same time, and returns the collection of responses once all Promises are fulfilled.
  4. Create a function that creates all Promises at the same time, but only returns the response of the first fulfilled Promise.

Countdown Timer Helper

To get a better gauge of how time is moving in these, I'm going to define a function that logs a timer every second. We will use this helper function as the first call inside of each of our challenges.

function startCountdownTimer() {
  seconds = 0;
  const int = setInterval(() => {
    seconds++;
    console.log(seconds);
    if (seconds >= 15) {
      clearInterval(int);
    }
  }, 1000);
}
Enter fullscreen mode Exit fullscreen mode

Start All

Let's try our first code challenge: Create a function that creates all Promises at the same time, console.log the Promise responses.

It's look at a simple example that creates all our promises, and when each status changes to fulfilled, we console.log the response. Note how a(), b() and c() are created at nearly the same moment, but the .then triggers are asynchronous. In our example, b() resolves after 5 seconds is created before a(), but the .then from a() still triggers first.

function startAll() {
  startCountdownTimer();

  b().then((result) => console.log(result));
  a().then((result) => console.log(result));
  c().then((result) => console.log(result));
}
Enter fullscreen mode Exit fullscreen mode

A video showing start all

Start All Sequentially

Let's try our second code challenge: Create a function that sequentially creates each Promise, creating one after the next. console.log when each promise is finished.

What methods do we know that will run only once the previous Promise's response changes to fulfilled? Again, we can use a .then, however this time we can return the next promise by invoking it in the .then callback.

function startSequentially() {
  startCountdownTimer();

  a()
    .then((result) => {
      console.log(result);
      return b();
    })
    .then((result) => {
      console.log(result);
      return c();
    })
    .then((result) => {
      console.log(result);
    });
}
Enter fullscreen mode Exit fullscreen mode

A video showing promises sequientially

Return All Responses at Once

This one is a little tricker, let's try our next code challenge: Create a function that creates all Promises at the same time, and returns the collection of responses once all Promises are fulfilled.

Up until now, we didn't have any tools to collect all responses from a collection of promises. Fortunately, a few class functions are given to us out of the box! Promise.all returns a promise once each of the promises passed has been fulfilled. Responses are gathered from all argument promises and stored as the Promise response in an array. Just like any Promise, we can chain from this newly return promise with a .then.

function allDone() {
  startCountdownTimer();

  const promises = [a(), b(), c()];
  Promise.all(promises).then((result) => console.log(result));
}
Enter fullscreen mode Exit fullscreen mode

A video showing all done

Return The First Resolve Response

We're almost there, let's try our last code challenge: Create a function that creates all Promises at the same time, but only returns the response of the first fulfilled Promise.

Just like before, ES6 gives us a class function that will return the first fulfilled response of a collection of Promises -- Promise.race. Let's give it a shot.

function firstResolved() {
  startCountdownTimer();

  const promises = [a(), b(), c()];
  Promise.race(promises).then((result) => console.log(result));
}
Enter fullscreen mode Exit fullscreen mode

Video showing the first resolved

Conclusion

Asynchronous programming isn't a concept that can be covered quickly, or understood in a day. For me, simply interacting with Promises in the console, as well as using a timer helper function, has helped me to gain a better understanding of Promise states, results, and promise chaining. If you have any comments or suggestions about my code or examples above, please let me know in the comments below!

Top comments (0)