JavaScript Promises Explained for Beginners
A promise is exactly what it sounds like, a guarantee that you'll get something back, even if not right away.
The Problem Promises Were Built to Solve
Before promises, JavaScript handled async tasks with callbacks. And for simple cases, callbacks work fine. But the moment you have multiple async steps that depend on each other, things fall apart quickly.
Imagine you need to log a user in, then fetch their profile, then load their dashboard settings, each step depending on the previous one:
login(username, password, function(user) {
getProfile(user.id, function(profile) {
loadSettings(profile.settingsId, function(settings) {
renderDashboard(user, profile, settings, function() {
console.log("Done!");
// What if any of these fail?
// How do we handle errors at each level?
});
});
});
});
This is the callback pyramid. Each step nests deeper. Error handling has to be done at every level separately. Reading it is exhausting. Debugging it is worse.
Promises were designed to fix this. They let you write the same sequence of steps in a clean, flat, readable way, and handle all errors in one place.
What a Promise Actually Is
A promise is an object that represents a value that doesn't exist yet, but will at some point in the future.
Think about ordering something online. The moment you place the order, you get a confirmation, a promise that your package will arrive. You don't have the package yet. But you have a guarantee. You can go about your day, and when the package arrives, you deal with it then.
A JavaScript promise works the same way. You call a function that does something async, like fetching data from a server, and instead of waiting around, you immediately get back a promise object. That promise is a placeholder. When the actual result is ready, the promise delivers it.
const orderConfirmation = placeOrder("JavaScript Book"); // returns a promise immediately
// ... you can do other things here
// when the order is delivered, handle it
The Three States of a Promise
Every promise is always in one of three states:
Pending — The async operation has started but hasn't finished yet. The promise is still waiting. This is the initial state.
Fulfilled — The operation completed successfully. The promise has a value now.
Rejected — Something went wrong. The promise has a reason (an error) explaining what failed.
Once a promise moves from pending to either fulfilled or rejected, it stays that way permanently. A promise can't go back to pending. It can't flip from fulfilled to rejected. It settles once, and that's final.
Creating a Basic Promise
You create a promise using the Promise constructor. It takes a function with two arguments, resolve and reject. Call resolve when things go well, and reject when they don't.
const myPromise = new Promise(function(resolve, reject) {
const success = true;
if (success) {
resolve("Here is your data!"); // promise is fulfilled
} else {
reject(new Error("Something went wrong.")); // promise is rejected
}
});
Here's a more realistic version, simulating a server request with a timer:
function fetchUserData(userId) {
return new Promise(function(resolve, reject) {
console.log("Fetching user...");
setTimeout(function() {
if (userId > 0) {
resolve({ id: userId, name: "Priya Sharma" }); // success
} else {
reject(new Error("Invalid user ID")); // failure
}
}, 1500); // simulates a 1.5 second network delay
});
}
The function starts the async work and immediately returns a promise. The promise is pending while the timer runs. When the timer fires, it either resolves with the user data or rejects with an error.
Handling Success and Failure: .then() and .catch()
You react to a promise's outcome using .then() for success and .catch() for failure.
fetchUserData(1)
.then(function(user) {
console.log("Got the user:", user.name);
})
.catch(function(error) {
console.log("Failed:", error.message);
});
.then() receives the value that resolve() was called with. .catch() receives the error that reject() was called with.
If the promise fulfills:
Fetching user...
(1.5 seconds later)
Got the user: Priya Sharma
If the promise rejects (passing userId = -1):
Fetching user...
(1.5 seconds later)
Failed: Invalid user ID
Think of .then() and .catch() like this: you ordered a package. When it arrives (then), you open it and use it. If it gets lost (catch), you deal with the problem.
The Full Lifecycle, Step by Step
Let's trace exactly what happens when you use a promise:
console.log("1. Start");
const promise = fetchUserData(1); // pending — running in background
console.log("2. Promise created — still pending");
promise
.then(function(user) {
console.log("4. User arrived:", user.name); // fulfilled
})
.catch(function(error) {
console.log("4. Error:", error.message); // if rejected
});
console.log("3. Moving on — not waiting");
Output:
1. Start
2. Promise created — still pending
3. Moving on — not waiting
Fetching user...
(1.5 seconds later)
4. User arrived: Priya Sharma
Lines 1, 2, and 3 all run synchronously and immediately. The promise runs in the background. When it settles, line 4 runs. JavaScript never froze. It never waited. It kept going and handled the result when it was ready.
Promise Chaining: The Real Power
This is where promises genuinely shine. When you return a value (or another promise) from inside a .then(), it creates a new promise — and you can chain another .then() onto it.
Instead of nesting, you chain:
fetchUserData(1)
.then(function(user) {
console.log("Got user:", user.name);
return fetchUserPosts(user.id); // return another promise
})
.then(function(posts) {
console.log("Got posts:", posts.length, "total");
return fetchPostComments(posts[0].id); // return another promise
})
.then(function(comments) {
console.log("Got comments:", comments.length, "total");
})
.catch(function(error) {
console.log("Something went wrong:", error.message);
});
Each .then() waits for the previous step to finish before running. If anything rejects at any step, the .catch() at the end handles it. You don't need a separate error handler at every level, just one at the bottom catches everything.
Compare that to the callback version from the beginning:
// Callbacks — nesting inward, error handling everywhere
login(user, pass, function(user) {
getProfile(user.id, function(profile) {
loadSettings(profile.settingsId, function(settings) {
// three levels deep already
}, handleError);
}, handleError);
}, handleError);
// Promises — flat, readable, one error handler
login(user, pass)
.then(user => getProfile(user.id))
.then(profile => loadSettings(profile.settingsId))
.then(settings => renderDashboard(settings))
.catch(handleError);
Same logic. Completely different readability. The promise version reads almost like a to-do list, do this, then this, then this, and if anything goes wrong, handle it here.
Passing Values Through the Chain
Each .then() receives the value returned by the previous one. You can also pass plain values, not just promises, and the chain carries them forward:
fetchUserData(1)
.then(function(user) {
return user.name.toUpperCase(); // returning a plain value
})
.then(function(upperName) {
console.log("Name in caps:", upperName); // "PRIYA SHARMA"
return upperName.length;
})
.then(function(nameLength) {
console.log("Name has", nameLength, "characters"); // 12
});
Every step gets exactly what the previous step returned. The chain flows cleanly downward.
Handling Errors in a Chain
One .catch() at the end handles rejections from any step in the chain:
fetchUserData(1)
.then(function(user) {
return fetchUserPosts(user.id);
})
.then(function(posts) {
return fetchPostComments(posts[0].id);
})
.catch(function(error) {
// handles failure from ANY of the above steps
console.log("Failed somewhere:", error.message);
});
If fetchUserData rejects, the chain skips directly to .catch(). If fetchUserPosts rejects, same thing. The intermediate .then() calls are skipped. The error falls through to the handler.
You can also add .catch() in the middle of a chain to recover from an error and continue:
fetchUserData(1)
.then(function(user) {
return fetchUserPosts(user.id);
})
.catch(function(error) {
console.log("Posts failed, using empty array:", error.message);
return []; // recover — continue chain with empty array
})
.then(function(posts) {
console.log("Posts (may be empty):", posts.length);
// chain continues whether posts loaded or not
});
The .finally() Method
.finally() runs after the promise settles, whether it fulfilled or rejected. It's perfect for cleanup that needs to happen no matter what, like hiding a loading spinner:
showLoadingSpinner();
fetchUserData(1)
.then(function(user) {
displayUser(user);
})
.catch(function(error) {
showErrorMessage(error.message);
})
.finally(function() {
hideLoadingSpinner(); // always runs — success or failure
});
The spinner goes away whether the data loaded successfully or something went wrong.
Comparing Callbacks and Promises Side by Side
Here's the same task, fetch a user, then their posts, written both ways:
// ── Callbacks ──────────────────────────────────────────
fetchUser(1, function(error, user) {
if (error) {
console.log("Error:", error.message);
return;
}
fetchPosts(user.id, function(error, posts) {
if (error) {
console.log("Error:", error.message);
return;
}
console.log(user.name, "has", posts.length, "posts");
});
});
// ── Promises ───────────────────────────────────────────
fetchUser(1)
.then(user => fetchPosts(user.id).then(posts => ({ user, posts })))
.then(({ user, posts }) => {
console.log(user.name, "has", posts.length, "posts");
})
.catch(error => {
console.log("Error:", error.message); // handles ALL errors
});
The callback version needs error handling at each level. It nests inward. The promise version stays flat, and one .catch() covers everything.
Things to Know
You can't cancel a promise. Once you create it, it will either resolve or reject, you can't stop it mid way.
Promises are eager. The code inside new Promise(...) runs immediately when you create the promise, not when you call .then().
Unhandled rejections are a problem. If a promise rejects and there's no .catch(), you'll get a warning in the console (and an error in Node.js). Always attach a .catch().
// Bad — no .catch()
fetchUserData(1)
.then(user => console.log(user.name));
// If this rejects, the error disappears silently
// Good — always catch
fetchUserData(1)
.then(user => console.log(user.name))
.catch(error => console.error("Unhandled:", error.message));
Key Takeaways
A promise is a placeholder for a value that will exist in the future, success or failure.
A promise is always in one of three states: pending, fulfilled, or rejected.
Once a promise settles (fulfills or rejects), it can never change state again.
Use
.then()to handle success,.catch()to handle failure,.finally()for cleanup.Promise chaining lets you write sequential async steps in a flat, readable way, no nesting.
A rejection at any point in a chain skips to the nearest
.catch().You can return values or new promises from
.then(), both are handled the same way by the next.then().Always add a
.catch(), unhandled rejections cause warnings and hard to debug failures.
Quick Reference
// Create a promise
const p = new Promise((resolve, reject) => {
if (success) resolve(value);
else reject(new Error("reason"));
});
// Handle outcome
p
.then(value => { /* success */ })
.catch(error => { /* failure */ })
.finally(() => { /* always runs */ });
// Chain promises
fetchUser(1)
.then(user => fetchPosts(user.id)) // return a promise
.then(posts => posts.length) // return a plain value
.then(count => console.log(count))
.catch(error => console.error(error.message));
// Recover from an error mid-chain
fetch(url)
.catch(() => fetch(backupUrl)) // try backup on failure
.then(response => response.json());
// Three promise states
// pending → operation in progress
// fulfilled → resolve() was called, has a value
// rejected → reject() was called, has an error
A promise is JavaScript's way of saying: "I don't have the answer right now, but I will. And when I do, I'll call you."

