Promises in JavaScript: An Introduction

In Brief
A promise represents the eventual completion or failure of an operation and lets you attach reactions with then(), catch() and finally(). It helps organise results from asynchronous host APIs, but it does not make CPU‑heavy JavaScript concurrent or move work off the current agent. Remember that then() returns a new promise, and always handle rejection somewhere in the chain.
JavaScript executes one job at a time on an agent. Browsers and server runtimes provide timers, networking and other host facilities that can complete work later, whilst promises schedule reactions as jobs once they settle. Promises organise those eventual results; they do not make arbitrary work concurrent or non‑blocking.
Promises were introduced in ES6. They let us organise asynchronous results into a chain of then() and catch() handlers, rather than nesting each next step inside another callback. Let's look at how that works, starting with a timer and then a pair of API requests.
What are Promises?
A promise is an object that represents the eventual completion or failure of an asynchronous operation and its resulting value. In other words: a promise is a placeholder for a future value that we expect to receive and can act upon once it arrives.
A promise has three states:
Pending:
The initial state of a promise when it is created.Fulfilled:
The state of a promise when it has been resolved successfully and has value.Rejected:
The state of a promise when it has been rejected with a reason, indicating that the operation failed.
A promise can transition from the pending state to either the fulfilled or rejected state, but once it is in one of these states, it cannot transition to any other state.
Creating a Promise
The Promise constructor receives an executor function, which JavaScript calls synchronously during construction. The executor may start a host operation or arrange for another event to settle the promise later, but the constructor itself does not make its code asynchronous.
To start with, let's look at an example where we create a promise which resolves after a timeout of 2 seconds:
const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Promise resolved!');
}, 2000);
});In this example, we create a new promise using the Promise constructor and pass an executor function as its argument. The executor function takes two parameters: resolve and reject. These are callback functions that are used to transition the promise to either the fulfilled or rejected state.
The executor calls the host‑provided setTimeout API synchronously. The host later queues the callback, which resolves the promise with 'Promise resolved!' after at least two seconds.
Using Promises
To consume a promise, we use the then() method, which is called on the promise object. The then() method takes two callback functions as its arguments: one to handle the fulfilled state and one to handle the rejected state.
Here's an example using myPromise from the previous example:
myPromise.then(
(result) => {
console.log(result); //=> 'Promise resolved!'
},
(error) => {
console.log(error); // never gets called in this example
}
);In this example, we call the then() method on the myPromise object and pass two callback functions as its arguments. The first callback function is called if the promise is fulfilled (result) and receives the result of the promise, which in this case is the string 'Promise resolved!'.
The second callback function (error) is called if the promise is rejected, but since we didn't reject the promise in our example, this will never get called.
Chaining Promises
Promises can be chained together to handle a sequence of asynchronous operations. When a promise is fulfilled, we can return another promise, which allows us to chain multiple asynchronous operations together.
Let's look at a more involved example:
const getUser = (userId) => {
return fetch(`/users/${userId}`).then((response) => {
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
});
};
const getPosts = (userId) => {
return fetch(`/users/${userId}/posts`).then((response) => {
if (!response.ok) {
throw new Error('Failed to fetch posts');
}
return response.json();
});
};
getUser(1)
.then((user) => getPosts(user.id))
.then((posts) => {
// handle posts data
})
.catch((error) => {
// handle error
});This is a modified example from a project I've recently worked on. Here, we define two functions that return promises: getUser and getPosts. The getUser function fetches a user from an API and returns a promise that resolves with the user data. The getPosts function takes a user ID as a parameter and fetches the posts for that user from the same API. It returns a promise that resolves with that user's post data. Fetch already returns a promise, so we can return its chain directly. Throwing for an unsuccessful HTTP response keeps the chain rejected; network failures and errors parsing the JSON also reach the final catch handler.
As an aside, the more eagle‑eyed amongst you may have also noticed that I've used template literals in there to create our API endpoint URLs.
We then call these both by chaining the two functions together using the then() method. First, we call getUser() and then we use the then() method to pass the user object to the getPosts() function. This allows us to fetch the posts for the user and handle them in the same chain of promises.
Limitations and Pitfalls
Whilst promises are a powerful pattern for handling asynchronous code, there are some limitations and pitfalls to be aware of.
Forgetting to Handle Errors
One common pitfall I've seen during many code reviews is forgetting to handle errors. We can have the most robust API in the world, and it will still fail from time to time. Promise rejections should be handled at an appropriate boundary. An unhandled rejection is reported according to the host: browsers emit an unhandledrejection event, whilst Node.js behaviour depends on its version and command‑line policy. It must not be described as silently ignored.
Misunderstanding then()
Another pitfall is the misunderstanding of the then() method. The then() method returns a new promise that is resolved with the value returned by the callback function. If the callback function returns a promise, the new promise will be resolved with the value of the returned promise, not the promise itself. This can lead to unexpected behaviour if not understood properly.
Not a One‑Size‑Fits‑All Remedy
A promise represents one eventual outcome. Callbacks and events can still be a better fit for work that reports several values over time. The newer async/await syntax works with promises rather than replacing them: an async function returns a promise, and await lets us write the steps around its result more directly. In late 2016, check support in the browsers and runtime you're targeting before relying on that syntax.
The Wrap‑up
Promises fit when one asynchronous result needs to be composed, returned, or awaited. If a handler starts another promise‑based operation and the next step must wait for it, return that promise from the handler so the chain adopts its state. Return the whole chain from a function when its caller must await or compose the result. Handle rejection where the application can respond meaningfully.