JavaScript Promises Explained: From Callbacks to Async Programming

Hi there! I'm Aditya, a passionate Full-Stack Developer driven by a love for turning concepts into captivating digital experiences. With a blend of creativity and technical expertise, I specialize in crafting user-friendly websites and applications that leave a lasting impression. Let's connect and bring your digital vision to life!
If you've ever worked with APIs, databases, file uploads, authentication, or payment gateways in JavaScript, you've already worked with asynchronous programming—even if you didn't realize it.
Before learning Promises and Async/Await, it's important to understand why asynchronous programming exists. Once you understand the problem, the solutions become much easier to remember.
Let's start from the beginning.
What is Synchronous JavaScript?
JavaScript is a single-threaded programming language. This simply means it can execute one task at a time.
Consider this example:
console.log("Start");
console.log("Learning JavaScript");
console.log("End");
Output:
Start
Learning JavaScript
End
JavaScript executes each line one after another.
It doesn't move to the next statement until the current one has finished executing.
This behavior is called synchronous execution.
Why Synchronous Execution Isn't Always Enough
Imagine you're building an e-commerce application.
A user clicks the Place Order button.
Your application now needs to:
Verify payment
Save the order in the database
Send a confirmation email
Update inventory
All of these operations take time.
Now imagine JavaScript waited for every operation to finish before executing the next line of code.
During that time:
The page could become unresponsive.
The user couldn't interact with the application.
The overall experience would feel slow.
Clearly, this isn't ideal.
This is exactly why asynchronous programming exists.
Understanding Asynchronous JavaScript
Instead of waiting for a long-running task to finish, JavaScript delegates that work to the browser or the Node.js runtime.
Once the task completes, JavaScript is notified and continues executing the required code.
A simple example looks like this:
console.log("Order Pizza");
setTimeout(() => {
console.log("Pizza Delivered");
}, 3000);
console.log("Watching Netflix");
Output:
Order Pizza
Watching Netflix
Pizza Delivered
Notice something interesting.
JavaScript didn't stop for three seconds.
Instead, it continued executing the remaining code while the timer was running in the background.
A Simple Real-World Analogy
Imagine you've ordered a pizza from Zomato.
Synchronous Approach
Place the order.
Stand outside the restaurant for 30 minutes.
Do absolutely nothing.
Collect the pizza.
This wastes your time.
Asynchronous Approach
Place the order.
Continue working.
Watch a movie.
Complete your assignment.
Receive a notification when the pizza is ready.
This is exactly how asynchronous JavaScript works.
Instead of waiting, it continues executing other tasks until the long-running operation finishes.
Who Actually Performs Asynchronous Operations?
One of the biggest misconceptions among beginners is that JavaScript performs asynchronous operations.
It doesn't.
JavaScript simply asks another component to perform those operations.
For example:
| Operation | Performed By |
|---|---|
setTimeout() |
Browser or Node.js Runtime |
fetch() |
Browser |
| File System | Node.js |
| Database Queries | Database Driver / Node.js |
| DOM Events | Browser |
When you write:
setTimeout(() => {
console.log("Done");
}, 2000);
JavaScript is essentially saying:
"Please start this timer. Let me know once it's finished."
While the timer is running, JavaScript continues executing other code.
What is a Callback Function?
A callback is simply a function passed as an argument to another function.
Example:
function greet(name) {
console.log(`Hello ${name}`);
}
function processUser(callback) {
const name = "Aditya";
callback(name);
}
processUser(greet);
Output:
Hello Aditya
Here, greet is passed as an argument and executed later.
That's all a callback really is.
A callback itself is not asynchronous.
It's just a function.
Whether it's executed immediately or later depends on the function using it.
Callbacks in Asynchronous Code
Callbacks become especially useful when working with asynchronous operations.
Example:
console.log("Order Placed");
setTimeout(() => {
console.log("Pizza Delivered");
}, 3000);
console.log("Watching Netflix");
The function inside setTimeout() is a callback.
Instead of executing immediately, the browser waits for three seconds before asking JavaScript to execute it.
This is one of the most common examples of asynchronous callbacks.
The Problem with Callbacks
Callbacks work well for simple tasks.
However, problems start appearing when multiple asynchronous operations depend on one another.
Imagine an application that needs to:
Authenticate the user.
Fetch the user's profile.
Retrieve their recent orders.
Fetch payment history.
Using callbacks, the code might look like this:
login(() => {
getProfile(() => {
getOrders(() => {
getPaymentHistory(() => {
console.log("Everything Completed");
});
});
});
});
As more operations are added, the code becomes deeply nested.
Reading, debugging, and maintaining this structure quickly becomes difficult.
This problem is known as Callback Hell, also called the Pyramid of Doom.
Why Were Promises Introduced?
Promises were introduced to solve the problems caused by callback hell.
Instead of nesting one callback inside another, Promises allow asynchronous operations to be chained together in a much cleaner way.
Instead of writing:
login(() => {
getProfile(() => {
getOrders(() => {
getPaymentHistory(() => {
});
});
});
});
You can write:
login()
.then(getProfile)
.then(getOrders)
.then(getPaymentHistory)
.catch(handleError);
The execution flow becomes much easier to understand.
Error handling also becomes significantly simpler.
Understanding Promises
A Promise represents the future result of an asynchronous operation.
Think about ordering a laptop online.
After placing the order, the website gives you an order confirmation.
That confirmation isn't the laptop itself.
Instead, it's a guarantee that one of two things will happen in the future:
You'll receive the laptop.
You'll receive a notification explaining why the order couldn't be completed.
A Promise works in exactly the same way.
It doesn't contain the final result immediately.
Instead, it represents a value that will become available sometime in the future.
Promise States
Every Promise can exist in only one of three states.
| State | Meaning | Real-World Example |
|---|---|---|
| Pending | The operation is still running. | Pizza is being prepared. |
| Fulfilled | The operation completed successfully. | Pizza has been delivered. |
| Rejected | The operation failed. | The restaurant cancelled the order. |
The lifecycle looks like this:
Pending
│
├────────► Fulfilled
│
└────────► Rejected
A Promise changes its state only once.
Once fulfilled or rejected, it can never change again.
Creating Your Own Promise
Many APIs such as fetch() already return a Promise.
However, sometimes you'll build your own asynchronous function.
In those cases, you create a Promise yourself.
Example:
function sleep(milliseconds) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
Usage:
await sleep(2000);
console.log("Two seconds completed.");
Here, the Promise is fulfilled only after the timer finishes.
When Should You Create Your Own Promise?
A common question is:
If
fetch()already returns a Promise, why do we ever writenew Promise()?
The answer is simple.
You create your own Promise whenever you're building your own asynchronous API.
Imagine you're implementing an image upload feature.
The upload may finish in two seconds, five seconds, or even twenty seconds.
You don't know beforehand.
Your function should resolve only when the upload actually completes.
function uploadImage(file) {
return new Promise((resolve, reject) => {
cloudinary.upload(file, (error, result) => {
if (error) {
reject(error);
return;
}
resolve(result);
});
});
}
Notice that we never guessed the time.
The Promise is resolved only when Cloudinary reports that the upload has finished.
Understanding resolve() and reject()
Whenever you create a Promise, JavaScript provides two functions.
new Promise((resolve, reject) => {
});
These functions control the Promise's final state.
| Function | Purpose |
|---|---|
resolve(value) |
Marks the Promise as fulfilled and returns a value. |
reject(error) |
Marks the Promise as rejected and returns an error. |
Example:
const payment = new Promise((resolve, reject) => {
const success = true;
if (success) {
resolve("Payment Successful");
} else {
reject("Payment Failed");
}
});
The important thing to remember is that resolve() is not based on time.
It's based on completion.
If a database query finishes, call resolve().
If a payment gateway confirms a transaction, call resolve().
If an image upload completes, call resolve().
The event decides when the Promise settles—not a timer.
Common Misconceptions
| Myth | Reality |
|---|---|
| JavaScript performs asynchronous work. | The browser or Node.js runtime performs it. |
| A Promise immediately contains the data. | A Promise represents data that will arrive in the future. |
resolve() should always be called after setTimeout(). |
resolve() should be called when the asynchronous operation actually finishes. |
| Callbacks are always asynchronous. | A callback is simply a function. It can be synchronous or asynchronous depending on how it's used. |
| Promises and Async/Await are completely different concepts. | Async/Await is built on top of Promises. |
Frequently Asked Interview Questions
Why were Promises introduced?
Promises were introduced to solve callback hell, improve readability, and provide better error handling for asynchronous operations.
What are the three states of a Promise?
Pending, Fulfilled, and Rejected.
Can a Promise change its state multiple times?
No.
Once a Promise is fulfilled or rejected, its state becomes permanent.
Does fetch() return the response data?
No.
fetch() returns a Promise that eventually resolves to a Response object.
When should you create your own Promise?
Whenever you're building your own asynchronous function that doesn't already return a Promise.
What's the biggest mistake beginners make?
Many beginners think JavaScript performs asynchronous work itself.
In reality, asynchronous operations are handled by the browser or Node.js runtime, while JavaScript coordinates the execution.
Working with Promises
Now that you understand what a Promise is and why it exists, it's time to learn how we actually work with it.
Creating a Promise is only one side of the story.
The more common task in real-world applications is consuming Promises returned by APIs like fetch(), database queries, authentication libraries, payment gateways, and cloud services.
Let's understand how JavaScript provides different methods to handle these Promises.
Understanding .then()
Whenever a Promise is fulfilled, JavaScript executes the function passed to .then().
Think of .then() as saying:
"Once this task finishes successfully, execute this code."
Example:
fetch("/api/users")
.then((response) => {
console.log(response);
});
Here, fetch() immediately returns a Promise.
JavaScript doesn't wait for the server response.
Instead, it registers the callback inside .then() and continues executing the remaining code.
When the server finally responds, JavaScript executes the callback.
Real-World Example
Imagine you ordered a laptop online.
You don't sit in front of the warehouse waiting for it.
Instead, you continue doing your work.
When the delivery arrives, you receive a notification.
That notification is similar to .then().
It runs only after the task completes successfully.
Returning Values from .then()
One important thing many beginners don't know is that every .then() returns a new Promise.
This makes Promise chaining possible.
Example:
fetch("/api/users")
.then((response) => response.json())
.then((users) => {
console.log(users);
});
Flow:
fetch()
↓
Response Object
↓
response.json()
↓
Actual User Data
Notice that the second .then() receives the value returned from the previous .then().
This is known as Promise Chaining.
Understanding .catch()
Asynchronous operations don't always succeed.
A server might crash.
The internet connection may fail.
A database might become unavailable.
A payment could be rejected.
Whenever a Promise is rejected, JavaScript executes the function inside .catch().
Example:
fetch("/invalid-api")
.then((response) => response.json())
.catch((error) => {
console.log(error);
});
Think of .catch() as a centralized error handler.
Instead of checking errors after every step, you can handle them in one place.
Real-World Example
Imagine you're booking movie tickets.
If the payment succeeds, you receive the ticket.
If the payment fails, the app immediately shows an error message.
The error screen behaves just like .catch().
Understanding .finally()
Sometimes you want to execute code regardless of success or failure.
That's exactly what .finally() does.
Example:
fetch("/api/users")
.then((response) => response.json())
.catch((error) => {
console.log(error);
})
.finally(() => {
console.log("Request Finished");
});
Whether the request succeeds or fails, "Request Finished" will always be printed.
Common Use Cases
Hide loading spinner
Close database connection
Stop progress bar
Clean temporary resources
Promise Chaining
Suppose your application performs these steps.
Login
Fetch Profile
Fetch Orders
Fetch Payment History
Each operation depends on the previous one.
Instead of nesting callbacks, we can chain Promises.
login()
.then(() => getProfile())
.then(() => getOrders())
.then(() => getPaymentHistory())
.catch((error) => {
console.log(error);
});
This code is much easier to read than callback hell.
The flow becomes almost identical to normal English.
Real-World Example: User Dashboard
Imagine you're building a dashboard.
The dashboard needs:
User Profile
Notifications
Latest Orders
A beginner might write:
const profile = await getProfile();
const notifications = await getNotifications();
const orders = await getOrders();
Although this works, the requests are executed one after another.
If each request takes one second, the total time becomes roughly three seconds.
A better solution is to execute them together.
const [profile, notifications, orders] = await Promise.all([
getProfile(),
getNotifications(),
getOrders(),
]);
Now all requests start at the same time.
The total execution time becomes approximately the duration of the slowest request instead of the sum of all requests.
This is one of the most common Promise optimizations used in production applications.
Promise Utility Methods
JavaScript provides four utility methods that are frequently asked in interviews.
| Method | Waits For | Rejects? | Best Use Case |
|---|---|---|---|
Promise.all() |
All Promises to succeed | Yes | Loading multiple independent APIs |
Promise.allSettled() |
All Promises to finish | No | Dashboard widgets, analytics |
Promise.race() |
First settled Promise | Depends | Timeouts, fastest response |
Promise.any() |
First successful Promise | Only if all fail | CDN mirrors, backup servers |
Let's understand each one.
Promise.all()
Promise.all() waits until every Promise is fulfilled.
If even one Promise rejects, the entire Promise rejects immediately.
Example:
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments(),
]);
Timeline
User ---------- 1s
Posts ---------------- 2s
Comments ------- 1s
Total Time = 2s
If getPosts() fails, the entire operation fails.
This behavior is called Fail Fast.
Best Use Cases
Dashboard APIs
Loading product details
Fetching independent resources
Parallel database queries
Promise.allSettled()
Unlike Promise.all(), this method never fails because of one rejected Promise.
Instead, it returns the result of every Promise.
Example:
const results = await Promise.allSettled([
getProfile(),
getNotifications(),
getBlogs(),
]);
Example Output:
[
{
status: "fulfilled",
value: { name: "Aditya" },
},
{
status: "rejected",
reason: Error("Server Error"),
},
{
status: "fulfilled",
value: [],
},
]
This method is perfect when partial success is acceptable.
Real-World Example
Suppose a homepage contains:
Hero Banner
Trending Products
Blogs
Recommendations
If the Blogs API fails, you still want to show the remaining sections.
Promise.allSettled() makes this possible.
Promise.race()
Promise.race() returns the first Promise that settles.
A Promise is considered settled when it is either fulfilled or rejected.
Example:
const result = await Promise.race([
fetch(serverA),
fetch(serverB),
fetch(serverC),
]);
Timeline:
Server A ---------- 300ms
Server B ---------------- 500ms
Server C ---- 150ms
The response from Server C will be returned first.
Common Use Cases
API timeout implementation
Selecting the fastest server
Network fallback mechanisms
Promise.any()
Promise.any() waits for the first successful Promise.
Unlike Promise.race(), it ignores rejected Promises.
Example:
const response = await Promise.any([
fetch(primaryServer),
fetch(backupServer),
fetch(thirdServer),
]);
Timeline:
Primary Server ----- Error
Backup Server ---------------- 400ms
Third Server --------- 200ms
The third server wins because it is the first successful Promise.
If every Promise fails, JavaScript throws an AggregateError.
Common Use Cases
CDN mirrors
Multiple backend regions
Backup APIs
Image delivery networks
Promise.all() vs Promise.allSettled()
| Promise.all() | Promise.allSettled() |
|---|---|
| Requires every Promise to succeed | Waits for every Promise to finish |
| Rejects immediately on first failure | Never rejects because of one failure |
| Faster fail-fast behavior | Better when partial data is acceptable |
| Common for critical workflows | Common for dashboards |
Promise.race() vs Promise.any()
| Promise.race() | Promise.any() |
|---|---|
| First settled Promise wins | First successful Promise wins |
| Error can win the race | Errors are ignored until every Promise fails |
| Useful for timeouts | Useful for multiple mirror servers |
Common Mistakes
| Mistake | Better Approach |
|---|---|
Using Promise.all() when partial data is acceptable |
Use Promise.allSettled() |
| Running independent API calls sequentially | Use Promise.all() |
Forgetting to return a Promise inside .then() |
Always return the next Promise when chaining |
| Ignoring rejected Promises | Always handle errors with .catch() or try...catch |
Interview Questions
What is Promise Chaining?
Promise chaining is the process of connecting multiple asynchronous operations using consecutive .then() calls, where each step receives the result of the previous Promise.
When should you use Promise.all()?
When multiple independent asynchronous operations must all succeed before continuing.
When should you use Promise.allSettled()?
When you want the result of every Promise, regardless of whether some succeed or fail.
What is the difference between Promise.race() and Promise.any()?
Promise.race() returns the first settled Promise (success or failure), while Promise.any() returns the first fulfilled Promise and ignores failures until every Promise is rejected.
Which Promise method is most commonly used in production?
For most web applications:
Promise.all()is used for parallel API requests.Promise.allSettled()is used when partial failures should not break the UI.Promise.race()is commonly used for request timeouts.Promise.any()is useful for redundant servers and CDN fallback systems.
Understanding Async/Await
Promises solved the problem of callback hell, but developers still had one complaint.
Long Promise chains were sometimes difficult to read.
Consider this example.
fetch("/api/user")
.then((response) => response.json())
.then((user) => getOrders(user.id))
.then((orders) => getPaymentHistory(orders))
.catch((error) => {
console.log(error);
});
Although this is much cleaner than callback hell, it still doesn't look like normal JavaScript code.
To improve readability, JavaScript introduced Async/Await.
The important thing to remember is:
Async/Await does not replace Promises. It is built on top of Promises.
Every await works with a Promise.
Every async function returns a Promise.
What Does async Do?
Whenever you add the async keyword before a function, JavaScript automatically makes that function return a Promise.
Example:
async function greet() {
return "Hello";
}
Many beginners think this function returns a string.
It doesn't.
It returns:
Promise { "Hello" }
The above code is almost equivalent to:
function greet() {
return Promise.resolve("Hello");
}
This is why you can write:
const message = await greet();
console.log(message);
What Does await Do?
The await keyword pauses the execution of the current async function until the Promise settles.
Example:
async function getUser() {
const response = await fetch("/api/user");
const user = await response.json();
return user;
}
Notice something important.
JavaScript doesn't block the entire application.
It only pauses the current async function.
Other JavaScript tasks can continue executing.
How Async/Await Works Internally
Many developers think await is magic.
It isn't.
Internally, JavaScript is still working with Promises.
Example:
const response = await fetch("/api/user");
Conceptually behaves like:
fetch("/api/user")
.then((response) => {
// Continue execution
});
This is why understanding Promises first is extremely important.
If you understand Promises, Async/Await becomes very easy.
Why Can't We Use await Everywhere?
The following code is invalid.
const response = await fetch("/api/users");
It throws an error because await can only be used:
Inside an async function.
At the top level of an ES Module.
Correct example:
async function getUsers() {
const response = await fetch("/api/users");
return response.json();
}
Error Handling with Try...Catch
With Promises, errors are handled using .catch().
fetch("/api/user")
.then((response) => response.json())
.catch((error) => {
console.log(error);
});
With Async/Await, we use try...catch.
async function getUser() {
try {
const response = await fetch("/api/user");
const user = await response.json();
return user;
} catch (error) {
console.log(error);
}
}
Think of the mapping like this.
| Promise | Async/Await |
|---|---|
.then() |
await |
.catch() |
try...catch |
.finally() |
finally |
When Should You Use .then()?
Although Async/Await is more popular today, .then() is still useful.
Good use cases:
Simple Promise chains
Event listeners
Library code
Functional Promise composition
Example:
button.addEventListener("click", () => {
fetch("/api/user")
.then((response) => response.json())
.then((user) => {
console.log(user);
});
});
This is perfectly valid code.
When Should You Use Async/Await?
Async/Await is usually preferred when you're writing business logic.
Example:
async function checkout() {
const user = await getUser();
const payment = await verifyPayment();
const order = await createOrder();
await sendConfirmationEmail();
return order;
}
This reads almost like normal English.
That is why modern applications heavily prefer Async/Await.
Async/Await vs Promise.then()
| Async/Await | Promise.then() |
|---|---|
| Cleaner syntax | Slightly more verbose |
| Easy to read | Can become difficult with long chains |
| Uses try...catch | Uses .catch() |
| Preferred in business logic | Useful in short Promise chains |
| Built on top of Promises | Native Promise API |
Neither approach is better.
Choose the one that makes your code easier to understand.
Should You Still Learn Promises?
Absolutely.
Many beginners try to skip Promises and directly learn Async/Await.
This is a mistake.
Consider this line.
const response = await fetch("/api/user");
Why does await work here?
Because fetch() returns a Promise.
Without understanding Promises, Async/Await becomes difficult to reason about.
Promises are the foundation.
Async/Await is simply a cleaner way to consume them.
Real-World Example: User Login
Imagine you're building an authentication system.
The steps are:
Validate credentials.
Generate JWT.
Save session.
Return user profile.
Using Async/Await:
async function login(credentials) {
const user = await validateUser(credentials);
const token = await generateToken(user);
await saveSession(user.id, token);
return {
user,
token,
};
}
This flow is easy to understand and maintain.
Real-World Example: File Upload
Suppose a user uploads an image.
The application should:
Upload image.
Save URL in database.
Send notification.
Return updated profile.
async function updateProfile(file) {
const image = await uploadImage(file);
const profile = await saveProfile(image.url);
await sendNotification(profile.id);
return profile;
}
Notice that each step depends on the previous one.
This is an ideal use case for Async/Await.
Common Mistakes
Forgetting await
const user = fetch("/api/user");
console.log(user);
Output:
Promise { <pending> }
Correct:
const user = await fetch("/api/user");
Forgetting async
function getUser() {
const response = await fetch("/api/user");
}
This throws an error.
Correct:
async function getUser() {
const response = await fetch("/api/user");
}
Using Sequential Requests Unnecessarily
Bad:
const users = await getUsers();
const posts = await getPosts();
const comments = await getComments();
Good:
const [users, posts, comments] = await Promise.all([
getUsers(),
getPosts(),
getComments(),
]);
If the requests are independent, run them in parallel.
Best Practices
Prefer Async/Await for application logic.
Use
Promise.all()for independent requests.Always handle errors.
Don't wrap an existing Promise inside another Promise unless necessary.
Avoid unnecessary sequential requests.
Keep async functions focused on one responsibility.
Interview Questions
Does Async/Await replace Promises?
No.
Async/Await is built on top of Promises.
Does every async function return a Promise?
Yes.
Even if you return a string or a number, JavaScript automatically wraps it inside a Promise.
Can await work without a Promise?
No.
await is designed to work with Promises (or thenables).
Why is Async/Await preferred?
Because it improves readability and makes asynchronous code look like synchronous code.
Which is faster: Promises or Async/Await?
Neither.
Async/Await internally uses Promises.
The performance difference is negligible.
Choose the one that improves readability.
What is the biggest advantage of Async/Await?
The biggest advantage is maintainability.
Large asynchronous workflows become much easier to understand, debug, and modify.
Final Thoughts
Asynchronous programming is one of the most important concepts in modern JavaScript.
Everything from API calls and database queries to authentication, file uploads, payments, and cloud services relies on it.
Instead of memorizing syntax, focus on understanding the problem each feature solves.
Remember the evolution of asynchronous JavaScript:
Callback
↓
Callback Hell
↓
Promise
↓
Async/Await
Once you understand this journey, every asynchronous API in JavaScript starts making much more sense.
Most importantly, don't treat Promises and Async/Await as separate topics.
They are different ways of solving the same problem, with Async/Await simply providing a cleaner syntax on top of Promises.


