Mert Özen Further With Every Line
Frontend JavaScript Intermediate — #09

JavaScript Promises: Three States, the then/catch/finally Chain and Error Propagation

Mert Özen Nov 3, 2026 8 min 5 views
JavaScript Promises: Three States, the then/catch/finally Chain and Error Propagation

We build the three promise states with a restaurant buzzer analogy and work through new Promise, then/catch/finally, chaining, error propagation, Promise.all and race, and wrapping callbacks.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

We create a promise with two console.log calls and a resolve inside. Outside there's a then and another console.log. In what order are the numbers printed?

const p = new Promise((resolve) => {
  console.log("1");
  resolve("2");
  console.log("3");
});

p.then((value) => console.log(value));
console.log("4");

Make a guess before you keep reading. If you remember the event loop picture from the previous post you have an advantage, but there's one more small surprise hidden in this question. We'll explain the answer once we've seen how a promise is created.

What Is a Promise? The Buzzer in Your Hand

When you order at a busy burger place, they often hand you a small buzzer that vibrates. Your burger isn't ready yet, but you're holding something that will let you know when it is. You can sit down, check your phone, chat with a friend. When the buzzer goes off, either your food is ready or you get the "sorry, we ran out" news.

A promise is exactly that buzzer: an object that stands for a result you don't have yet but will get later. It joined the language with ES2015. With the callback pattern from the previous post, you handed your callback to a function to get the result; with a promise, the function hands you an object and you decide what to do with the result. Control is back in your hands.

Every promise is in one of three states:

pending: the work isn't finished yet. The buzzer is in your hand and hasn't gone off.

fulfilled: the work finished successfully and produced a value. The burger is ready.

rejected: the work failed and produced a reason, usually an Error. They ran out of ingredients.

Once a promise leaves pending for one of the other two states, it's considered settled and never changes again. A burger you were told is ready can't later turn into "actually, we were out."

Making Your Own Promise with new Promise

Most of the time you don't create promises yourself; browser APIs like fetch hand you a ready-made one. But to understand how they work, let's build one by hand. Paste this into your browser console and try it:

function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(`${ms} ms passed`), ms);
  });
}

wait(1000).then((message) => console.log(message)); // after 1 second: "1000 ms passed"

The function you pass to new Promise is called the executor. The engine hands it two functions: resolve and reject. When the work succeeds you call resolve(value); when it fails you call reject(error):

function rollDice() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const value = Math.floor(Math.random() * 6) + 1;
      if (value === 1) {
        reject(new Error("You rolled a 1, you lose"));
      } else {
        resolve(value);
      }
    }, 500);
  });
}

rollDice()
  .then((value) => console.log(`Dice: ${value}`))
  .catch((error) => console.error(error.message));

Run it a few times; sometimes you'll see the result, sometimes the error. Get into the habit of always passing an Error object to reject. You can pass a plain string, but then you lose the stack trace and the instanceof Error checks we saw earlier won't work.

Now let's look at the opening question. The executor runs synchronously, the moment new Promise is called. So 1 is printed first, resolve("2") fulfills the promise right away, but the executor keeps going and 3 is printed too. Then the then is registered and 4 is printed. The function you passed to then doesn't run immediately, even though the promise is already fulfilled; its turn comes once the currently running code is done. The answer is 1, 3, 4, 2.

Note this behavior: calling resolve doesn't end the executor, and once a promise is settled, any further resolve or reject call is silently ignored.

then, catch and finally

There are three methods for dealing with a promise's result. then(fn) runs on success and receives the value. catch(fn) runs on failure and receives the error. finally(fn), which arrived with ES2018, runs regardless of the outcome and takes no argument; it's ideal for cleanup such as hiding a loading indicator:

console.log("Loading...");

rollDice()
  .then((value) => console.log(`Dice: ${value}`))
  .catch((error) => console.error(error.message))
  .finally(() => console.log("Loading finished"));

We've seen that then callbacks always run after the currently running code finishes. One more detail: when Promise.resolve().then(...) and setTimeout(..., 0) sit side by side, the promise's callback runs first. Promises have a queue of their own with higher priority. We'll learn that queue's name and exactly when it gets drained in the event loop post of the Advanced series; for now, "promise callbacks come before timers" is all you need.

Chaining: then Returns a New Promise

This is where the real power of promises lies. Every call to then returns a new promise. Whatever you return from its callback, the new promise settles with that value. If what you return is itself a promise, the chain waits for it to finish.

wait(500)
  .then(() => 2)
  .then((n) => n * 10)
  .then((n) => wait(500).then(() => n + 1))
  .then((n) => console.log(n)); // 21 after about 1 second

Remember the callback pyramid from the previous post. If we write promise-returning versions of the same functions, the pyramid becomes a flat chain:

function getUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id <= 0) reject(new Error("Invalid user id"));
      else resolve({ id, name: "Ellie" });
    }, 500);
  });
}

function getOrders(userId) {
  return wait(500).then(() => [{ id: 101 }, { id: 102 }]);
}

function getOrderDetail(orderId) {
  return wait(500).then(() => ({ id: orderId, total: 349.9 }));
}

getUser(1)
  .then((user) => getOrders(user.id))
  .then((orders) => getOrderDetail(orders[0].id))
  .then((detail) => console.log(`First order: ${detail.total}`))
  .catch((error) => console.error(error.message));

The code now flows down instead of to the right, and each step receives the result of the one before. Best of all is the single catch at the end: an error anywhere in the chain lands there.

Error Propagation

When an error happens in a chain, whether through reject or a throw inside a then, the promise becomes rejected and the chain skips every then in between until it reaches the first catch. It's like the promise version of the try/catch we saw earlier:

wait(300)
  .then(() => {
    throw new Error("Something went wrong in step two");
  })
  .then(() => console.log("This line never runs"))
  .catch((error) => {
    console.error("Caught:", error.message);
    return "default value";
  })
  .then((value) => console.log("The chain carries on:", value));

Look at the last line. catch returns a promise as well, and when it catches the error and returns a normal value, the chain carries on successfully from there. So catch is a point where you can say "handled." If you can't handle the error there, you can throw again to pass it on to the next catch.

And if there's no catch at all? If nobody catches a rejected promise, the browser shows an "Uncaught (in promise)" error in the console and fires the unhandledrejection event on window. We pointed to that event in the error handling post when we talked about global error handling.

A First Look at Promise.all and Promise.race

When you want to start several independent tasks at once and wait for all of them, you use Promise.all. It takes an array of promises and gives you a single promise that resolves to an array of results, in the same order:

Promise.all([wait(1000), wait(500), wait(200)])
  .then((results) => console.log(results));
// after about 1 second: ["1000 ms passed", "500 ms passed", "200 ms passed"]

Because the three tasks run in parallel, the total time is not 1700 ms but as long as the longest one: one second. A warning: if any of the promises rejects, Promise.all rejects immediately without waiting for the others.

Promise.race, on the other hand, takes the result of the first promise to settle. Its classic use is setting up a timeout:

function timeout(ms) {
  return new Promise((_, reject) => {
    setTimeout(() => reject(new Error("Timed out")), ms);
  });
}

Promise.race([wait(2000), timeout(1000)])
  .then((message) => console.log(message))
  .catch((error) => console.error(error.message)); // "Timed out"

Alongside these there are also Promise.allSettled and Promise.any. We'll compare them, and when to choose which, in the post on advanced async in the Advanced series.

Turning a Callback into a Promise

If you have an old function that works with callbacks, you can wrap it in a function that returns a promise. For functions that follow the error-first callback convention, the pattern is always the same:

// Old callback-based API
function loadSettings(callback) {
  setTimeout(() => callback(null, { theme: "dark" }), 300);
}

// Promise wrapper
function loadSettingsAsync() {
  return new Promise((resolve, reject) => {
    loadSettings((error, settings) => {
      if (error) reject(error);
      else resolve(settings);
    });
  });
}

loadSettingsAsync().then((settings) => console.log(settings.theme)); // "dark"

We wired the callback's error branch to reject and its success branch to resolve. Now you can chain this function with other promises or pass it to Promise.all. This is exactly the most legitimate place to write new Promise by hand: bringing an API that doesn't know about promises into the promise world.

Common Pitfalls

The first, and sneakiest, trap is forgetting return inside then:

getUser(1)
  .then((user) => {
    getOrders(user.id); // no return!
  })
  .then((orders) => console.log(orders)); // undefined

The orders were requested, but nobody returned the promise. The chain doesn't wait for it, and the next then runs right away with undefined. The orders arrive later, but nobody is listening. The rule is simple: if you start a promise inside then, return it.

The second is nesting then calls and bringing the pyramid back. Something like getUser(1).then((user) => getOrders(user.id).then(...)) works, but throws away the flatness that chaining gives you. Nesting is tempting when later steps also need a value from an earlier step; the clean solution to that is the subject of the next post.

The third is leaving the catch off the end of the chain. The error doesn't silently vanish, but you can't show the user anything; all that's left is a red line in the console.

The fourth is needlessly wrapping something that already returns a promise in new Promise. Instead of writing new Promise((resolve) => resolve(fetch(url))), just return fetch(url) directly. Unnecessary wrapping easily leads to errors losing their way.

A Small Experiment

★☆☆ Using the wait function, build a chain that prints "One", "Two" and "Three" one after another, at 1, 2 and 3 seconds. Keep it a single chain with no nested then.

★★☆ Predict the output and its order, then try it:

setTimeout(() => console.log("A"), 0);

Promise.resolve("B")
  .then((value) => {
    console.log(value);
    throw new Error("C");
  })
  .catch((error) => console.log(error.message))
  .finally(() => console.log("D"));

console.log("E");

★★★ At the end of the getUser, getOrders and getOrderDetail chain you also want to print the user's name: "Ellie paid 349.9 on their first order". But at the last step of the chain all you have is detail. How would you solve this without nesting then? Try a few approaches.

Chains are far better than pyramids, but there are still plenty of then calls and arrow functions, and carrying values between steps, as in the last exercise, is a bit of a chore. In the next post we'll move on to async/await, which lets us write the same promises with syntax that reads like synchronous code.