JavaScript Promises Explained for Beginners
Before promises existed, JavaScript developers handled asynchronous operations with callbacks. It worked. But as applications grew more complex, callbacks had a habit of becoming a nightmare — nested inside each other, with error handling scattered everywhere, and logic that was nearly impossible to follow.
Promises were introduced to solve that. Not to make async code synchronous, but to make it readable, predictable, and manageable. This post explains what promises are, how they work from the inside out, and how to use them cleanly.
The problem promises solve
To appreciate promises, you need to feel the pain they replace. Callbacks first.
Suppose you're building a feature: load a user from the database, then load their orders, then calculate the total value of those orders. Each step depends on the previous one, and each is asynchronous.
With callbacks, it looks like this:
getUser(userId, function(err, user) {
if (err) {
console.error("Failed to get user:", err);
return;
}
getOrders(user.id, function(err, orders) {
if (err) {
console.error("Failed to get orders:", err);
return;
}
calculateTotal(orders, function(err, total) {
if (err) {
console.error("Failed to calculate:", err);
return;
}
console.log(`Total for ${user.name}: $${total}`);
});
});
});
This is callback hell — sometimes called the pyramid of doom. Every step nests deeper. Error handling repeats three times. The actual logic (console.log) is buried at the bottom of four levels of indentation.
And this is only three steps. Real applications have more.
Now look at the same logic with promises:
getUser(userId)
.then(user => getOrders(user.id))
.then(orders => calculateTotal(orders))
.then(total => console.log(`Total: $${total}`))
.catch(err => console.error("Something failed:", err));
Same operations. One level of indentation. One error handler for everything. Reads top to bottom like a sentence.
That's the promise.
What a promise actually is
A promise is an object that represents the eventual result of an asynchronous operation. You don't have the value yet — but you have a placeholder for it, and you can attach code to run when the value arrives.
Think of it like ordering food at a restaurant. The waiter doesn't hand you the food immediately. They give you a receipt — a promise that food is coming. While you wait, you can plan what you'll do when it arrives (eat it) or what you'll do if it doesn't (complain and leave). The receipt doesn't give you food early. It gives you a way to handle both outcomes.
In JavaScript:
const promise = new Promise((resolve, reject) => {
// async work happens here
// call resolve(value) when successful
// call reject(error) when something fails
});
The function you pass to new Promise is called the executor. It runs immediately and synchronously. Inside it, you perform your async work (a network request, a file read, a database query), and when it finishes, you call either resolve with the result, or reject with an error.
The three states of a promise
Every promise lives in exactly one of three states at any given moment.
Pending — the initial state. The async work has started but hasn't finished. The promise has no result yet.
Fulfilled — the async work completed successfully. resolve() was called. The promise has a value.
Rejected — the async work failed. reject() was called. The promise has a reason (an error).
Two things to know about states:
A promise can only change state once. Fulfilled promises can't become rejected. Rejected promises can't become fulfilled.
Once settled (either fulfilled or rejected), the promise is immutable — it will always return the same value or the same error.
This predictability is one of the core reasons promises are easier to reason about than callbacks.
Creating a promise
Let's build a simple promise manually to see exactly how the states work:
// A promise that fulfils after 1 second
const myPromise = new Promise((resolve, reject) => {
console.log("Executor running..."); // runs immediately
setTimeout(() => {
const success = true; // change to false to test rejection
if (success) {
resolve("Operation completed successfully!");
} else {
reject(new Error("Something went wrong."));
}
}, 1000);
});
console.log("Promise created:", myPromise); // Promise { <pending> }
// The promise is pending here — the setTimeout hasn't fired yet
At the moment myPromise is logged, it's pending. One second later, the setTimeout fires, calls resolve(), and the promise becomes fulfilled.
Handling the result: .then(), .catch(), .finally()
Creating a promise doesn't do much on its own. You need to attach handlers that respond to the outcome.
.then() — handling success
.then() takes a callback that runs when the promise fulfils. The fulfilled value is passed as the argument.
myPromise.then(result => {
console.log(result); // "Operation completed successfully!"
});
.catch() — handling failure
.catch() takes a callback that runs when the promise rejects. The rejection reason (usually an Error) is passed as the argument.
const failingPromise = new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error("Database connection failed"));
}, 500);
});
failingPromise.catch(err => {
console.error(err.message); // "Database connection failed"
});
Using both together
fetch("https://api.example.com/users/1")
.then(response => response.json())
.then(user => {
console.log("Got user:", user.name);
})
.catch(err => {
console.error("Request failed:", err.message);
});
.catch() at the end catches rejection from any .then() in the chain — more on that in a moment.
.finally() — always runs
.finally() runs regardless of whether the promise fulfilled or rejected. It's for cleanup code that should always execute — hiding a loading spinner, closing a connection, resetting state.
let isLoading = true;
fetch("https://api.example.com/data")
.then(response => response.json())
.then(data => console.log(data))
.catch(err => console.error(err))
.finally(() => {
isLoading = false; // always runs, success or failure
console.log("Request finished.");
});
.finally() doesn't receive the fulfilled value or the rejection reason — it just runs. If you need those values, use .then() or .catch().
A realistic promise example
Rather than just using setTimeout, let's write something closer to real code. Here's a function that simulates fetching a user from a database:
function getUser(id) {
return new Promise((resolve, reject) => {
// Simulating a DB query with setTimeout
setTimeout(() => {
const users = {
1: { id: 1, name: "Aarav", email: "aarav@example.com" },
2: { id: 2, name: "Priya", email: "priya@example.com" },
3: { id: 3, name: "Rahul", email: "rahul@example.com" }
};
const user = users[id];
if (user) {
resolve(user);
} else {
reject(new Error(`User with ID ${id} not found`));
}
}, 300);
});
}
// Use it
getUser(2)
.then(user => console.log(`Found: \({user.name} — \){user.email}`))
.catch(err => console.error(err.message));
// → "Found: Priya — priya@example.com"
getUser(99)
.then(user => console.log(user))
.catch(err => console.error(err.message));
// → "User with ID 99 not found"
The function always returns a promise. The caller decides what to do with it using .then() and .catch(). The function itself doesn't know or care what the caller will do — it just resolves or rejects.
Promise chaining
This is where promises go from useful to genuinely powerful. .then() returns a new promise — which means you can chain .then() calls one after another, and each step receives the result of the previous one.
getUser(1)
.then(user => {
console.log("Step 1:", user.name);
return user.id; // pass the id to the next step
})
.then(userId => {
console.log("Step 2: got user id:", userId);
return getOrders(userId); // return a new promise
})
.then(orders => {
console.log("Step 3: got orders:", orders.length);
return orders.reduce((total, order) => total + order.amount, 0);
})
.then(total => {
console.log("Step 4: total is $" + total);
})
.catch(err => {
console.error("Something failed:", err.message);
// Catches errors from ANY step above
});
Each .then() either returns a plain value (passed directly to the next .then()) or a new promise (waited on before passing its result to the next .then()). This is what makes chaining work with async functions at each step.
The critical rule about returning
Inside a .then() callback, you must return the value or promise you want passed along. If you forget to return, the next .then() receives undefined.
// ❌ Forgot to return — next step gets undefined
getUser(1)
.then(user => {
getOrders(user.id); // result not returned!
})
.then(orders => {
console.log(orders); // undefined
});
// ✅ Return the promise — chaining works correctly
getUser(1)
.then(user => {
return getOrders(user.id); // returned!
})
.then(orders => {
console.log(orders.length); // correct
});
Error propagation through a chain
One of the best things about promise chains is how errors propagate. If any .then() in the chain throws an error or returns a rejected promise, execution skips all remaining .then() handlers and jumps straight to the nearest .catch().
getUser(1)
.then(user => {
return getOrders(user.id); // let's say this rejects
})
.then(orders => {
// Skipped entirely if getOrders rejected
return calculateTotal(orders);
})
.then(total => {
// Also skipped
console.log("Total:", total);
})
.catch(err => {
// Catches the rejection from getOrders (or any step)
console.error("Pipeline failed:", err.message);
});
Compare this to the callback version where you had to handle errors at every single step. One .catch() at the end covers the entire chain.
Promise.resolve() and Promise.reject()
Sometimes you need to create an already-settled promise — a promise that's immediately fulfilled or rejected without any async work.
// Already fulfilled
const resolved = Promise.resolve(42);
resolved.then(val => console.log(val)); // 42
// Already rejected
const rejected = Promise.reject(new Error("immediate failure"));
rejected.catch(err => console.error(err.message)); // "immediate failure"
These are useful when you need to return a promise from a function but already have the value synchronously:
function getConfig(key) {
const cache = { theme: "dark", lang: "en" };
if (cache[key] !== undefined) {
return Promise.resolve(cache[key]); // already have it
}
return fetchConfigFromServer(key); // need to fetch it
}
// Either way, the caller uses .then()
getConfig("theme").then(val => console.log(val)); // "dark"
Working with multiple promises
What if you need to run several async operations and wait for all of them?
Promise.all() — wait for everything
const userPromise = getUser(1);
const ordersPromise = getOrders(1);
const configPromise = getConfig("theme");
Promise.all([userPromise, ordersPromise, configPromise])
.then(([user, orders, config]) => {
// All three finished — results destructured in order
console.log(user.name, orders.length, config);
})
.catch(err => {
// If ANY one rejects, this fires immediately
console.error("One of them failed:", err.message);
});
Promise.all() runs all promises in parallel (simultaneously, not sequentially). It fulfils when every promise fulfils, and rejects as soon as any single promise rejects.
Use this when you need all results before continuing, and the operations are independent of each other.
Promise.allSettled() — wait for everything, regardless of outcome
const results = await Promise.allSettled([
getUser(1),
getUser(99), // this one rejects
getConfig("theme")
]);
results.forEach(result => {
if (result.status === "fulfilled") {
console.log("Success:", result.value);
} else {
console.log("Failed:", result.reason.message);
}
});
Unlike Promise.all(), Promise.allSettled() never rejects. It waits for every promise to settle and gives you an array describing each outcome. Use this when you want to attempt several operations and handle each result individually, even if some fail.
Promise.race() — first one wins
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Request timed out")), 5000)
);
Promise.race([fetchData(), timeout])
.then(data => console.log("Got data:", data))
.catch(err => console.error(err.message));
Promise.race() settles with the result of whichever promise settles first — fulfilled or rejected. It's commonly used to implement timeouts.
Promises vs callbacks: the real comparison
Callbacks aren't wrong — they're still used in many places and work fine for simple one-off async calls. The difference becomes obvious as complexity grows. Promises scale better because chaining stays flat and error handling is centralised.
What comes after promises
Once you're comfortable with promises, the next step is async/await — syntax introduced in ES2017 that lets you write promise-based code that looks synchronous:
// Promise chain
getUser(1)
.then(user => getOrders(user.id))
.then(orders => calculateTotal(orders))
.then(total => console.log(total))
.catch(err => console.error(err));
// Same thing with async/await
async function loadUserTotal(userId) {
try {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const total = await calculateTotal(orders);
console.log(total);
} catch (err) {
console.error(err);
}
}
async/await doesn't replace promises — it's built on top of them. Every async function returns a promise. Every await expression unwraps one. Understanding promises deeply makes async/await immediately intuitive, rather than something you cargo-cult without knowing why it works.
Summary
Promises solve the readability and maintainability problems that callbacks created as async code grew complex. They represent a value that isn't ready yet — but will be — and give you a clean way to say "when it's ready, do this."
The key ideas to hold onto:
A promise is always in one of three states: pending, fulfilled, or rejected
Once settled, it stays settled — same value or same error, every time
.then()handles success,.catch()handles failure,.finally()always runsChains stay flat because
.then()returns a new promiseOne
.catch()at the end of a chain handles errors from any stepPromise.all()for parallel operations,Promise.allSettled()when you need all results regardless of failure
Promises are one of those things that feel abstract until the moment they click. When they do, your async code will never look the same.

