
Promises are one of those JavaScript concepts that sound intimidating but click instantly once you see the right mental model. This post breaks them down from scratch, covering why they exist, how they work, and how to use them properly.
You order a coffee. The barista doesn't hand it to you immediately. They give you a receipt and say "we'll call your name when it's ready." That receipt is your promise. It doesn't have the coffee yet. But it represents a future value. You can sit down, check your phone, do whatever. When the coffee is ready, you get called. If they run out of oat milk, they still call you, just with bad news.
That's exactly what a JavaScript Promise is.
Before promises, JavaScript developers handled asynchronous operations using callbacks. A callback is just a function you pass into another function so it can be called later when the work is done. Sounds fine in theory.
Here's what it looks like in practice:
javascriptLoading syntax highlighter...
This is what people call "callback hell." Each async operation nests inside the previous one. Error handling repeats at every level. The indentation alone tells you something is wrong. The deeper you go, the harder it is to follow the logic, debug issues, or add new steps.
Promises were introduced in ES6 to fix exactly this. They let you write async code that reads more like a straight line, handles errors in one place, and is much easier to reason about.
A Promise is an object that represents the eventual result of an async operation. It's a placeholder. Right now, the value isn't there yet. But JavaScript gives you this object to work with immediately, and you attach handlers that run when the value arrives.
┌─────────────────────────────────────────────────────┐ │ PROMISE OBJECT │ │ │ │ Represents a value that doesn't exist yet, │ │ but will (or won't) at some point in the future │ └─────────────────────────────────────────────────────┘
Every Promise exists in one of three states at any given moment.
┌──────────────────────────────────────────────────────────────┐ │ PROMISE LIFECYCLE │ │ │ │ ┌───────────┐ │ │ │ PENDING │ <-- Initial state │ │ └─────┬─────┘ │ │ │ │ │ ┌──────────────┴───────────────┐ │ │ │ │ │ │ ▼ ▼ │ │ ┌────────────┐ ┌──────────────┐ │ │ │ FULFILLED │ │ REJECTED │ │ │ │ (resolved) │ │ (failed) │ │ │ └────────────┘ └──────────────┘ │ │ │ │ Once settled (fulfilled or rejected), │ │ a promise CANNOT change state again. │ └──────────────────────────────────────────────────────────────┘
Pending is the starting state. The operation is still in progress.
Fulfilled means the operation completed successfully. The promise now has a value.
Rejected means something went wrong. The promise now has a reason (usually an error).
Here's the key detail that trips people up: once a promise settles into either fulfilled or rejected, it's done. It will never change state again. This is different from an event emitter, which can fire multiple times.
You create a promise using the new Promise() constructor. It takes a function with two parameters: resolve and reject. You call resolve when the work succeeds and reject when it fails.
javascriptconst myPromise = new Promise((resolve, reject) => { const success = true; // imagine this is the result of some async work if (success) { resolve("Here's your data!"); } else { reject(new Error("Something went wrong")); } });
In real code you'd rarely create promises this way directly. Most of the time you're working with promises returned by existing APIs like fetch, database clients, or timers. But knowing how they're built helps you understand what you're consuming.
Once you have a promise, you attach handlers to it. These three methods are how you do that.
.then() runs when the promise fulfills. .catch() runs when it rejects. .finally() runs in both cases, once the promise settles.
javascriptfetch("https://api.example.com/user/1") .then((response) => response.json()) .then((user) => { console.log("Got user:", user.name); }) .catch((error) => { console.error("Request failed:", error.message); }) .finally(() => { console.log("Request finished, regardless of outcome"); });
The .catch() at the end isn't just for the last .then(). It catches errors from any step in the chain. That's one of the core advantages over callbacks where you had to handle errors separately at each level.
CALLBACK APPROACH PROMISE APPROACH ───────────────── ──────────────── getUser(id, (err, user) => { getUser(id) if (err) handle(err) .then(user => getPosts(user.id)) getPosts(user.id, (err, posts) => { .then(posts => getComments(posts[0].id)) if (err) handle(err) .then(comments => console.log(comments)) getComments(posts[0].id, .catch(err => handle(err)) (err, comments) => { if (err) handle(err) console.log(comments) } ) }) }) Indentation grows rightward. Reads top to bottom. Error handling everywhere. One error handler covers all. Hard to modify. Easy to add/remove steps.
Both do the same thing. But the promise chain version is far easier to read, maintain, and debug. That gap only gets wider as the number of operations increases.
Here's the thing most beginners miss: .then() returns a new promise. Not the same one. A brand new promise that resolves with whatever you return from the .then() callback.
javascriptLoading syntax highlighter...
Each .then() in the chain receives the resolved value of the previous one. If you return a plain value, it gets wrapped in a fulfilled promise automatically. If you return a promise, the chain waits for that promise to settle before moving to the next .then().
This is the mechanism behind chaining. Each step passes its result to the next one, and errors bubble down to the nearest .catch().
┌──────────────────────────────────────────────────────────────┐ │ PROMISE CHAIN FLOW │ │ │ │ fetch(url) │ │ │ returns Promise<Response> │ │ ▼ │ │ .then(res => res.json()) │ │ │ returns Promise<Data> │ │ ▼ │ │ .then(data => process(data)) │ │ │ returns Promise<Result> │ │ ▼ │ │ .then(result => console.log(result)) │ │ │ │ │ ▼ │ │ .catch(err => console.error(err)) <-- catches from ALL │ │ steps above │ └──────────────────────────────────────────────────────────────┘
One important thing to get right: always return the inner promise when chaining. Forgetting the return is a classic bug where your chain doesn't wait for the async step.
javascript// WRONG: forgot to return, chain doesn't wait .then((user) => { fetch("/api/posts/" + user.id); // this promise is ignored }) // RIGHT: return it so the chain waits .then((user) => { return fetch("/api/posts/" + user.id); })
If you're already comfortable with promises, a few things worth knowing:
Unhandled promise rejections are a real problem. In modern Node.js, an unhandled rejection will terminate the process. Always attach a .catch() or use a try/catch with async/await (which is syntactic sugar over promises).
Promises are eager. The executor function (the one you pass to new Promise()) runs immediately when the promise is created, not when you call .then(). If you want lazy evaluation, you need a function that returns a promise, not just a promise.
.then(onFulfilled, onRejected) can actually take two arguments. The second is an error handler. But using .catch() is almost always cleaner and easier to understand.
Promise.all(), Promise.race(), Promise.allSettled(), and Promise.any() are built-in utilities for working with multiple promises at once. Each has a different strategy: all waits for all and fails fast on rejection, allSettled waits for all regardless, race takes the first to settle, any takes the first to fulfill.
javascriptLoading syntax highlighter...
This fetches a user, then fetches their posts, and returns both as one combined object. One .catch() handles any failure at any point.
Promises solve the callback hell problem by giving async operations a consistent structure and a single place to handle errors.
Every promise is either pending, fulfilled, or rejected. It can only ever be one of those at a time, and once it settles it stays that way.
.then() returns a new promise, which is what makes chaining possible. Each step gets the previous step's result.
.catch() anywhere in the chain will catch errors from any step above it. You don't need error handling at every level.
You'll rarely create promises manually in real-world code. But knowing how they work internally makes you much better at using the APIs that return them, including fetch, database drivers, file system APIs, and more.
Once this clicks, async/await starts to make a lot more sense since it's just a cleaner way to write promise-based code.
Related posts based on tags, category, and projects
Promises are the backbone of asynchronous JavaScript, and most developers use them daily without truly understanding how they work under the hood. This guide breaks down everything from the basics to internal mechanics, all six static methods, common pitfalls, and best practices, with analogies pulled straight from Marvel, DC, and anime.
Arrow functions are a cleaner, shorter way to write functions in JavaScript introduced in ES6. If you've been writing `function` keyword functions everywhere, this post will show you how to simplify your code without losing clarity.
Object-Oriented Programming (OOP) is a way of organizing code around real-world entities, and JavaScript supports it natively through classes. This post walks you through what OOP actually means, how classes work in JS, and why it makes your code cleaner and more reusable.
`this` is one of JavaScript's most misunderstood features, and `call()`, `apply()`, and `bind()` are the tools that let you control it. Once you understand who `this` points to and why, the rest clicks into place fast.
getUser(userId, function (err, user) {
if (err) {
console.error("Failed to get user:", err);
return;
}
getPosts(user.id, function (err, posts) {
if (err) {
console.error("Failed to get posts:", err);
return;
}
getComments(posts[0].id, function (err, comments) {
if (err) {
console.error("Failed to get comments:", err);
return;
}
console.log(comments);
});
});
});fetch("/api/users/1")
.then((response) => {
// response.json() returns a promise
// returning it here chains to the next .then()
return response.json();
})
.then((user) => {
// user is the resolved value of response.json()
console.log(user.name);
return user.id;
})
.then((userId) => {
// userId is whatever the previous .then() returned
console.log("User ID:", userId);
});function getUserWithPosts(userId) {
return fetch(`/api/users/${userId}`)
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json();
})
.then((user) => {
return fetch(`/api/posts?userId=${user.id}`)
.then((response) => response.json())
.then((posts) => {
return { user, posts };
});
});
}
getUserWithPosts(42)
.then(({ user, posts }) => {
console.log(`${user.name} has ${posts.length} posts`);
})
.catch((error) => {
console.error("Failed:", error.message);
});