DEV Community

Cover image for Understanding Promises in JavaScript
Rowland
Rowland

Posted on

Understanding Promises in JavaScript

Introduction

JavaScript is single-threaded, meaning it executes one operation at a time. But in the real world, applications constantly need to wait for API responses, file loading, or complex computations.

Historically, this reliance on waiting led to complex, nested callback functions, often referred to as “Callback Hell.” Promises were introduced to solve this exact problem, making asynchronous code cleaner, more readable, and far more efficient.

In this guide, we’ll cover the fundamentals of Promises, how to create and consume them, and some common pitfalls to avoid when using Promises


What is a Promise?

A Promise in JavaScript is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value. It acts as a placeholder for a value that is not yet available.

Every Promise exists in one of three mutually exclusive states:

  1. Pending: The initial state; the operation is still ongoing and has neither completed nor failed.
  2. Fulfilled (or Resolved): The operation completed successfully, and the Promise now holds a resulting value.
  3. Rejected: The operation failed, and the Promise holds a reason for the failure (usually an Error object).

Think of a Promise like ordering food:

  • You place the order (Promise is created — Pending).

  • The food arrives successfully (Promise is Fulfilled — resolve).

  • The kitchen can run out of ingredients (Promise is Rejected — reject).

How to create a Promise

You create a Promise using the Promise constructor, which takes one argument: an executor function.

The executor function is responsible for starting the asynchronous work. It receives two arguments, both of which are callback functions and is immediately executed when the promise constructor is called.

  • resolve: Call this function when the asynchronous operation succeeds.
  • reject: Call this function when the asynchronous operation fails.

Here is a simple example of using Promises


function myExecutor(resolve, reject) {
// async operation and logic to call resolve or reject goes here

const success = Math.random() > 0.5;

  setTimeout(function() {
    if (success) {
      // Operation succeeded, pass the result
      resolve("Operation completed successfully!");
    } else {
      // Operation failed, pass the reason/error
      reject(new Error("Operation failed"));
    }
  }, 2000);
}

const myPromise = new Promise(myExecutor);
console.log(myPromise);

Enter fullscreen mode Exit fullscreen mode

In the above example, setTimeout is the asynchronous operation that calls (after 2 seconds) the resolve callback function when the success variable is true otherwise it calls the reject callback function when success is false

The myExecutor named callback function is passed to the Promise constructor, but we could just as well used an inline anonymous or arrow callback function. Feel free to brush up on callback functions in my previous post on Understanding Callback Functions in JavaScript.

Side Note: Going forward, I’ll be declaring callback functions using anonymous functions. I thing it’s easier to tell that an argument is a function if it literally has the function keyword in it. You can follow along using your preferred method.

Return Value of a Promise

A Promise does not return a value like a traditional function does, instead it provides a state which represents the eventual completion (success or failure)

This is what will get logged to the console from the last line by running the code snippet above;

Promise {<pending>}
Enter fullscreen mode Exit fullscreen mode

It’s a Promise object with the pending state because that’s the initial state of every Promise, clicking on it in a browser console expands the object to show other possible states and return values.

Consuming Promises

Once a Promise is created, you use methods like .then() and .catch() to "consume" its eventual value. These methods allow you to define what happens when the Promise transitions from Pending to either Fulfilled or Rejected.

The .then() Method

The .then() method takes up to two handler functions as arguments:

  • The first function (onFulfilled) accepts as an argument the result ( value returned from the *resolve* callback function ) and runs when the Promise is Fulfilled.
  • The second function (onRejected) runs when the Promise is Rejected but it is optional and often skipped in favor of .catch().

Using the simple example from before, we can consume the value returned by the resolve or rejected callback function by using the .then method and the handler functions ( onFulfilled / onRejected )


myPromise.then(
  function (result) {
    // This is the onFulfilled handler that runs if resolve() was called.
    console.log(result); // logs "Operation completed successfully!"
  },
  function (error) {
    // This is the onRejected handler runs if reject() was called.
    console.error(error.message); //  Logs "Operation failed"
  }
)

Enter fullscreen mode Exit fullscreen mode

Notice we log error.message to the console not just error because we passed an Error object to the reject() callback function not a string.

The .catch() Method

The .catch() method is syntactic sugar for .then(null, onRejected). It is the standard way to handle errors in Promises and is cleaner than putting the rejection handler inside .then(). We simply just moved the onRejected handler function to a different method.

The code snippet above can be rewritten as;

myPromise
  .then(function (result) {
    // This is the onFulfilled handler that runs if resolve() was called
    console.log(result); // logs "Operation completed successfully!"
  })
  .catch(function (error) {
    // This runs if reject() was called.
    console.error(error.message); // Logs "Operation failed"
  })
Enter fullscreen mode Exit fullscreen mode

How Promises Work

The image below can help visualize how Promises work

How Promises workSource: Shuai Li

From the diagram above, we can see that the state changes from pending to fulfilled if the async operation resolve with a value, that value is further passed to the .then() method for further operations.

The state can also change from pending to rejected if the async operation reject with an Error, that Error is further passed to the .catch() method for further handling

Chaining Promises

The greatest power of Promises is their ability to be chained. Because the .then() method always returns a new Promise, you can link multiple asynchronous operations together sequentially.

This is the key mechanism for escaping “Callback Hell,” as you can transform complex, deeply nested code into a flat, readable sequence.

A common example of this is the chaining done when fetching data using the fetch API:

fetch('https://example.com/api/users') // fetch() returns Promise 1
  .then(function (response) {
    // Process response from Promise 1 and return Promise 2 
    return response.json(); // response.json() returns a Promise
  })
  .then(function (data) {
    // Use the result of Promise 2
    console.log(data);
  })
  .catch(function (error) {
    // Catches any error from all steps in the chain
    console.error('Something went wrong:', error);
  })
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls to Avoid When Using Promises

Promises are a powerful tool for handling asynchronous operations in JavaScript, but they can be tricky to use correctly. Here are some common pitfalls to watch out for when working with Promises and how to avoid them.

(1) Forgetting That Promises Are Asynchronous:

Although promises can make our code look and feel synchronous, they are still asynchronous in nature, one can easily make the mistake of trying to access the resolved value synchronously.

const getData = () => {
fetch('https://example.com/api/users')
  .then(function (response) {
    return response.json();
  })
  .then(function (data) {
    return data; // Expecting to use the data outside the getData function
  })
  .catch(function (error) {
    return error;
  })
}

const result = getData(); // getData returns a Promise
console.log(result); // Logs Promise { <pending> }, not the data
Enter fullscreen mode Exit fullscreen mode

Because getData() function contains a Promise, it runs asynchronously so when the JavaScript engine reaches the line const result = getData() it simply assigns the initial pending state Promise Object to result variable before proceeding to the next line and not the data we expected.

To fix this, we can;

  • chain getData() with another .then() method without assigning it to a variable
const getData = () => {
fetch('https://example.com/api/users')
  .then(function (response) {
    return response.json()
  })
  .then(function (data) {
    return data 
  })
  .catch(function (error) {
    return error;
  })
}

getData().then(function (result) {
console.log(result); // Logs the data
})
Enter fullscreen mode Exit fullscreen mode
  • or use the top level await keyword before calling getData() like this:
const getData = () => {
fetch('https://example.com/api/users')
  .then(function (response) {
    return response.json()
  })
  .then(function (data) {
    return data 
  })
  .catch(function (error) {
    return error;
  })
}

const result = await getData(); // JS awaits the data before proceeding to the next line
console.log(result); // Logs the data
Enter fullscreen mode Exit fullscreen mode
(2) Not Returning Promises in .then() Chains:

When chaining .then() calls, forgetting to return a Promise inside a .then() handler breaks the chain and can cause unexpected behavior.

fetch('https://example.com/api/users')
  .then(function (response) {
    response.json(); // forgetting the return keyword
  })
  .then(function (data) {
    console.log(data); // data is undefined because the previous chain did not return a promise
  })
  .catch(function (error) {
    console.error('Something went wrong:', error);
  })
Enter fullscreen mode Exit fullscreen mode

To fix this; always return a Promise or value from .then() handlers.

(3) Failing to handle Promise Rejections/Failures:

If a Promise rejects and you don’t handle the error, it can cause unhandled Promise rejection warnings or crashes.

fetch('https://exmpl.com/api/users') // Typo that'll trigger reject()
  .then(function (response) {
    return response.json();
  })
  .then(function (data) {
    console.log(data);
  })
// No .catch() to handle errors
Enter fullscreen mode Exit fullscreen mode

Conclusion

Promises are one of the most important features in modern JavaScript because they simplify how we deal with asynchronous code. Instead of juggling nested callbacks and messy logic, Promises give us a clean, structured way to handle success and failure. They’re the foundation for even more powerful tools like async/await, which build on the same concepts but make code look synchronous.

As you work with Promises, remember the key ideas: a Promise represents a value that may be available now, later, or never. It can be pending, fulfilled, or rejected—and how you handle those outcomes determines how reliable your code will be.

By mastering Promises, you’ll not only write more maintainable and bug-free code, but you’ll also set yourself up to understand advanced JavaScript concepts with ease. The next time you fetch data, process files, or chain multiple async operations, you’ll see just how powerful Promises really are.

Top comments (4)

Collapse
 
oculus42 profile image
Samuel Rouse

Thanks for posting this! More content about promises is always a good thing! There are a couple of things I think could cause confusion, though.

Saying "breaks the chain" when talking about failing to return a promise could be misleading. The chain remains intact, we're just passing undefined as the value of the promise, just as any function without a return statement returns undefined. It can cause unexpected behavior, but in the same way failing to return a value from any function does.

And while I understand the use of function keyword for clarity, the use of arrow function "lambdas" can be really useful in preserving the readability of the promise chain for simple operations. We can even forego the anonymous functions altogether in some places. Taking your example, we can eliminate a lot of "boilerplate" while processing response.json(), and just pass console.log directly.

fetch('https://example.com/api/users') // fetch() returns Promise 1
  // Process response from Promise 1 and return Promise 2 
  .then((response) => response.json())
  // Use the result
  .then(console.log)
  .catch(function (error) {
    // Catches any error from all steps in the chain
    console.error('Something went wrong:', error);
  })
Enter fullscreen mode Exit fullscreen mode

Also, we don't have to return a promise. That's one of the things I really like about promise chains; the chain maintains the promise structure and will wrap other returns for us. This allows us to call utilities and other operations in the chain without worrying about requiring or returning promises.

Promise.resolve(4)
  .then(x => x + 2)
  .then(x => x ** 2)
  .then(console.log);
// 36
Enter fullscreen mode Exit fullscreen mode

While I wouldn't recommend chaining every synchronous function this way, it is useful, especially when testing or mocking functions, as once a promise chain is started we can use synchronous or asynchronous functions inside.

Side note: I would recommend setting the Canonical URL of the article to your Medium post so there's a link between the two instance of the article for SEO optimization.

Collapse
 
rowleks profile image
Rowland

Thank you so much for your detailed review!

I tried to make the post as beginner friendly as possible hence the verboseness of the code snippets. I'll implement your suggestions in my subsequent posts.

Collapse
 
imaginativeone profile image
Doug Franklin

Thank you for a SPLENDID description of promises and how to use them. In particular, the addition of the getData outermost arrow-function explanation is going to save many newbies a LOT of time (and unpulled hair). Your use of functions here, instead of arrow-function-(lambda) shortcuts is a most-welcome newbie-friendly style.

Collapse
 
rowleks profile image
Rowland

You're welcome.

Thank you also for reading and finding it helpful.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.