JavaScript Promises Explained for Beginners

Promises are how JavaScript handles operations that take time. Instead of freezing your code waiting for a result, a promise says "I'll get back to you later." This makes your code responsive and easier to read.
This is about understanding promises and writing better asynchronous JavaScript.
What Problem Do Promises Solve?
JavaScript runs one line at a time. But some operations take time: fetching data, reading files, database queries.
The Blocking Problem
Without promises, you'd write code that waits:
// This would freeze everything
const data = getDataFromServer(); // Takes 2 seconds
console.log(data); // Waits 2 seconds before running
doOtherStuff(); // Waits 4 seconds total
The entire program stops. Nothing else happens. This is bad for user experience.
Real Example: Restaurant
Think of ordering food at a restaurant.
The old way (blocking):
1. Place order
2. Stand at counter
3. Stare at kitchen
4. Wait (30 minutes)
5. Finally eat
You're blocked. You can't do anything else.
The promise way (non-blocking):
1. Place order
2. Get pager number
3. Go sit down
4. Do other things
5. Pager buzzes when food is ready
6. Pick it up and eat
You're not blocked. The pager is like a promise. It tells you "I'll notify you when your order is ready."
The Callback Approach
Before promises, developers used callbacks:
getDataFromServer(function(data) {
console.log(data);
doOtherStuff();
});
// Code continues here (non-blocking)
This works, but leads to "callback hell":
getUser(1, function(user) {
getPostsForUser(user.id, function(posts) {
getCommentsForPost(posts[0].id, function(comments) {
getAuthorForComment(comments[0].id, function(author) {
console.log(author.name);
// 4 levels deep - hard to read
});
});
});
});
What Promises Solve
Promises make asynchronous code readable:
// Much cleaner
const user = await getUser(1);
const posts = await getPostsForUser(user.id);
const comments = await getCommentsForPost(posts[0].id);
const author = await getAuthorForComment(comments[0].id);
console.log(author.name);
Same logic. Way more readable.
Understanding Promises as Future Values
A promise is an object that represents a value that doesn't exist yet.
Simple Analogy
Imagine ordering a birthday cake from a bakery.
The bakery gives you a receipt (the promise). The receipt doesn't contain the cake yet. But it promises:
- Eventually, you'll get a cake
- Or the bakery will tell you they ran out (rejection)
You can:
- Wait for the receipt to become the cake (
.then()) - Plan what to do with the cake (chain operations)
- Handle the "ran out of cake" situation (
.catch())
The receipt is like a promise. It's not the cake, but it represents the future cake.
Promise Definition
A promise is an object that represents:
- A value that doesn't exist yet
- But will exist in the future (or will fail)
const promise = new Promise((resolve, reject) => {
// Operation that takes time
setTimeout(() => {
resolve("Done!"); // Success - provide the value
}, 1000);
});
console.log(promise); // Promise object (not "Done!" yet)
promise.then((result) => {
console.log(result); // "Done!" (after 1 second)
});
The promise is created instantly. But the result arrives later.
Promise States
A promise has three states:
State 1: Pending
Promise created
|
v
Waiting for operation to complete
|
v
State: PENDING
The promise is waiting. The operation hasn't finished yet.
const promise = new Promise((resolve, reject) => {
// At this point, promise is PENDING
// Operation hasn't finished
setTimeout(() => {
resolve("Done!");
}, 1000);
});
// Here, promise is still PENDING
console.log(promise); // Promise { <pending> }
State 2: Fulfilled
Operation completes successfully
|
v
resolve() is called
|
v
State: FULFILLED
Value is now available
The operation succeeded. The promise has a value.
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Success!"); // Call resolve()
}, 1000);
});
promise.then((value) => {
console.log(value); // "Success!"
});
State 3: Rejected
Operation fails
|
v
reject() is called
|
v
State: REJECTED
Error is available
The operation failed. The promise has an error.
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
reject("Something went wrong!"); // Call reject()
}, 1000);
});
promise.catch((error) => {
console.log(error); // "Something went wrong!"
});
State Transitions
PENDING
|
├─ Operation succeeds → resolve() → FULFILLED
|
└─ Operation fails → reject() → REJECTED
A promise starts pending. Then it moves to fulfilled or rejected. Never both. Never back to pending.
Key Rule: Promises are Immutable
Once a promise is fulfilled or rejected, it can't change:
const promise = new Promise((resolve, reject) => {
resolve("First");
resolve("Second"); // Ignored
reject("Error"); // Ignored
});
promise.then((value) => {
console.log(value); // "First" only
});
Only the first resolve() or reject() matters. The rest are ignored.
Promise Lifecycle Diagram
Promise Created
|
v
Promise is PENDING
(Operation running in background)
|
├─ Success Path OR Failure Path
| |
v v
resolve("value") reject("error")
| |
v v
FULFILLED REJECTED
(Value ready) (Error ready)
| |
v v
.then(callback) .catch(callback)
| |
v v
Handler runs Handler runs
(with value) (with error)
Creating Promises
Creating a Promise Manually
const promise = new Promise((resolve, reject) => {
// resolve and reject are functions
// Call resolve(value) when successful
// Call reject(error) when failed
});
Simple Example: Timer Promise
const delayPromise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Done waiting!");
}, 2000);
});
console.log("Start");
delayPromise.then((message) => {
console.log(message);
});
console.log("End");
// Output:
// Start
// End
// (2 seconds later)
// Done waiting!
The code doesn't wait for the promise. It continues. After 2 seconds, the handler runs.
Example: Random Success or Failure
const randomPromise = new Promise((resolve, reject) => {
const random = Math.random();
if (random > 0.5) {
resolve("Lucky!");
} else {
reject("Unlucky!");
}
});
randomPromise
.then((message) => {
console.log("Success:", message);
})
.catch((error) => {
console.log("Error:", error);
});
Sometimes it succeeds. Sometimes it fails.
Handling Success and Failure
The .then() Method
.then() runs when the promise is fulfilled:
promise.then((value) => {
console.log("Promise succeeded:", value);
});
The callback receives the value from resolve().
The .catch() Method
.catch() runs when the promise is rejected:
promise.catch((error) => {
console.log("Promise failed:", error);
});
The callback receives the error from reject().
Both Together
const promise = new Promise((resolve, reject) => {
const success = Math.random() > 0.5;
if (success) {
resolve("It worked!");
} else {
reject("It failed!");
}
});
promise
.then((value) => {
console.log("Success:", value);
})
.catch((error) => {
console.log("Error:", error);
});
One or the other will run. Never both.
Real Example: Fetching Data
const fetchUserData = new Promise((resolve, reject) => {
// Simulate API call
setTimeout(() => {
const success = Math.random() > 0.2;
if (success) {
const user = { id: 1, name: "Alice" };
resolve(user);
} else {
reject("Network error");
}
}, 1000);
});
fetchUserData
.then((user) => {
console.log("User:", user);
console.log("Name:", user.name);
})
.catch((error) => {
console.log("Failed to fetch:", error);
});
The .finally() Method
.finally() runs regardless of success or failure:
promise
.then((value) => {
console.log("Success:", value);
})
.catch((error) => {
console.log("Error:", error);
})
.finally(() => {
console.log("Operation complete (success or failure)");
});
Use .finally() for cleanup code. Like closing a database connection.
Promise Chaining
Promises can be chained. Each .then() returns a new promise.
Why Chaining?
Instead of nested callbacks:
// Callback hell
getUser(1, function(user) {
getPostsForUser(user.id, function(posts) {
console.log(posts);
});
});
You can chain promises:
// Much cleaner
getUser(1)
.then((user) => {
return getPostsForUser(user.id);
})
.then((posts) => {
console.log(posts);
});
Linear. Readable. Each step is clear.
How Chaining Works
Each .then() returns a promise:
const step1 = getUser(1); // Returns promise
const step2 = step1.then((user) => {
return getPostsForUser(user.id); // Returns new promise
});
const step3 = step2.then((posts) => {
console.log(posts); // No return = promise resolves with undefined
});
Or chained together:
getUser(1)
.then((user) => {
return getPostsForUser(user.id);
})
.then((posts) => {
return getCommentsForPost(posts[0].id);
})
.then((comments) => {
console.log(comments);
})
.catch((error) => {
console.log("Something failed:", error);
});
Passing Data Through Chains
getUser(1)
.then((user) => {
console.log("Step 1:", user);
return getPostsForUser(user.id); // Pass to next step
})
.then((posts) => {
console.log("Step 2:", posts);
return posts[0]; // Pass post to next step
})
.then((firstPost) => {
console.log("Step 3:", firstPost);
});
Each step receives the return value from the previous step.
Error Handling in Chains
If any step fails, the chain stops and jumps to .catch():
getUser(1)
.then((user) => {
return getPostsForUser(user.id); // If this fails...
})
.then((posts) => {
return getCommentsForPost(posts[0].id); // This won't run
})
.then((comments) => {
console.log(comments); // This won't run
})
.catch((error) => {
console.log("Error caught here:", error); // Error caught here
});
One error anywhere in the chain is caught at the end.
Practical Example: Login Flow
authenticateUser(email, password)
.then((token) => {
console.log("Login successful");
return fetchUserData(token); // Use token to fetch data
})
.then((user) => {
console.log("User loaded:", user.name);
return updateLastLogin(user.id);
})
.then(() => {
console.log("Last login updated");
redirectToDashboard();
})
.catch((error) => {
console.log("Login failed:", error);
showErrorMessage(error);
});
Clean flow: authenticate → fetch → update → redirect.
Callback vs Promise Comparison
Callback Approach
// Nested callbacks - "callback hell"
getUser(1, function(err, user) {
if (err) {
console.log("Error:", err);
} else {
getPostsForUser(user.id, function(err, posts) {
if (err) {
console.log("Error:", err);
} else {
getCommentsForPost(posts[0].id, function(err, comments) {
if (err) {
console.log("Error:", err);
} else {
console.log("Comments:", comments);
}
});
}
});
}
});
Problems:
- Hard to read (pyramid of doom)
- Error handling scattered everywhere
- Difficult to follow the flow
- Easy to make mistakes
Promise Approach
// Clear chain - easy to follow
getUser(1)
.then((user) => {
return getPostsForUser(user.id);
})
.then((posts) => {
return getCommentsForPost(posts[0].id);
})
.then((comments) => {
console.log("Comments:", comments);
})
.catch((error) => {
console.log("Error:", error);
});
Benefits:
- Linear flow (top to bottom)
- Error handling in one place
- Easy to read
- Easy to modify
Async/Await (Built on Promises)
Modern JavaScript makes it even simpler:
// Built on top of promises - looks like synchronous code
async function loadComments() {
try {
const user = await getUser(1);
const posts = await getPostsForUser(user.id);
const comments = await getCommentsForPost(posts[0].id);
console.log("Comments:", comments);
} catch (error) {
console.log("Error:", error);
}
}
loadComments();
Same promise behavior, but the syntax is simpler.
Common Promise Patterns
Promise.all() - Wait for All
Run multiple operations in parallel and wait for all:
const user = getUser(1);
const posts = getPostsForUser(1);
const comments = getComments(1);
Promise.all([user, posts, comments])
.then(([userData, postsData, commentsData]) => {
console.log("All data loaded");
console.log("User:", userData);
console.log("Posts:", postsData);
console.log("Comments:", commentsData);
})
.catch((error) => {
console.log("One or more failed:", error);
});
If any promise fails, the whole thing fails.
Promise.race() - Wait for First
Run multiple operations and use the result of whichever finishes first:
const timeout = new Promise((resolve, reject) => {
setTimeout(() => reject("Timeout"), 5000);
});
const apiCall = fetchData();
Promise.race([apiCall, timeout])
.then((result) => {
console.log("Got result:", result);
})
.catch((error) => {
console.log("Failed or timed out:", error);
});
Useful for timeout handling.
Promise.resolve() - Instant Success
Create a promise that's already fulfilled:
const instantPromise = Promise.resolve("Done!");
instantPromise.then((value) => {
console.log(value); // "Done!" (runs immediately)
});
Promise.reject() - Instant Failure
Create a promise that's already rejected:
const failedPromise = Promise.reject("Error!");
failedPromise.catch((error) => {
console.log(error); // "Error!" (runs immediately)
});
Complete Example: Building a Promise
// Create a promise that simulates an API call
function fetchUserData(userId) {
return new Promise((resolve, reject) => {
// Simulate network delay
setTimeout(() => {
// Simulate success/failure
if (userId > 0) {
const user = { id: userId, name: "Alice", email: "alice@example.com" };
resolve(user); // Success
} else {
reject("Invalid user ID"); // Failure
}
}, 1000);
});
}
// Use the promise
fetchUserData(1)
.then((user) => {
console.log("Fetched user:", user.name);
return fetchUserPosts(user.id);
})
.then((posts) => {
console.log("User has", posts.length, "posts");
})
.catch((error) => {
console.log("Failed:", error);
})
.finally(() => {
console.log("Operation complete");
});
// Test with invalid ID
fetchUserData(-1)
.then((user) => {
console.log("User:", user);
})
.catch((error) => {
console.log("Error:", error);
});
Practice Assignment
1. Create a simple promise:
// Create a promise that resolves with "Hello"
// After 1 second
// Use .then() to log the result
2. Handle both success and failure:
// Create a promise that randomly resolves or rejects
// Implement .then() and .catch()
// Test multiple times
3. Chain multiple promises:
// Create three functions that return promises
// Each one should use the result of the previous
// Chain them together with .then()
// Add error handling with .catch()
4. Compare callbacks vs promises:
// Write the same operation with callbacks
// Then rewrite with promises
// Notice the difference in readability
5. Use Promise.all():
// Create three promises that fetch different data
// Use Promise.all() to wait for all of them
// Handle success and failure
Quick Recap
Promises represent values that don't exist yet but will exist in the future.
A promise solves the callback hell problem by making asynchronous code readable.
Promises are like a receipt at a restaurant: you don't have the cake yet, but the receipt promises you'll get it.
Pending state: promise is waiting for an operation to complete.
Fulfilled state: operation succeeded and the promise has a value.
Rejected state: operation failed and the promise has an error.
A promise transitions from pending to either fulfilled or rejected, never both.
Once a promise is fulfilled or rejected, it cannot change states again.
resolve(value) moves a promise to fulfilled state with a value.
reject(error) moves a promise to rejected state with an error.
.then(callback) handles successful completion (fulfilled state).
.catch(callback) handles failure (rejected state).
.finally(callback) runs regardless of success or failure.
Promise chaining allows multiple operations in sequence with
.then().then().then().Each
.then()returns a new promise, enabling chaining.Errors in a promise chain jump to the nearest
.catch().Promise.all() waits for multiple promises, failing if any fails.
Promise.race() waits for the first promise to complete.
Callbacks are harder to read and lead to "callback hell."
Promises are cleaner and more maintainable.
Async/await is built on top of promises and makes code even simpler.
Promises are the foundation of modern JavaScript asynchronous programming.
If you enjoyed this article, check out my other blogs on this profile. Connect with me: LinkedIn | GitHub | X (Twitter)




