Callbacks, Promises, and async/await
Asynchronous programming is essential in JavaScript. Whether you’re fetching data from an API, reading a file, or waiting for a user action, you need to handle operations that don’t complete immediately. This tutorial walks you through the evolution of async patterns in JavaScript—from callbacks to Promises to the modern async/await syntax.
Understanding Asynchronous JavaScript
JavaScript is single-threaded, meaning it can only do one thing at a time. But it doesn’t block when performing slow operations like network requests. Instead, it uses an event-driven, non-blocking model.
When you call a function that takes time to complete, you don’t want your program to freeze. Instead, you provide a callback—a function that runs later when the operation finishes.
Callbacks
A callback is a function passed as an argument to another function, to be executed after some operation completes.
function fetchData(callback) {
setTimeout(() => {
const data = { id: 1, name: "JavaScript" };
callback(data);
}, 1000);
}
fetchData((result) => {
console.log(result);
// { id: 1, name: "JavaScript" }
});
console.log("Fetching data...");
The setTimeout simulates an asynchronous operation. While waiting, JavaScript continues executing other code. When the timeout completes, the callback runs.
Callback Hell
When you have multiple dependent async operations, callbacks nest deeper and deeper:
getUser((user) => {
getOrders(user.id, (orders) => {
getOrderDetails(orders[0].id, (details) => {
console.log(details);
// Nested callbacks become hard to read
});
});
});
This nesting is called “callback hell” or the “pyramid of doom.” It makes code hard to read and maintain.
Promises
Promises provide a cleaner way to handle async operations. A Promise represents a value that may be available now, in the future, or never.
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const success = true;
if (success) {
resolve({ id: 1, name: "JavaScript" });
} else {
reject(new Error("Failed to fetch data"));
}
}, 1000);
});
}
fetchData()
.then((data) => {
console.log(data);
return data.id;
})
.then((id) => {
console.log("User ID:", id);
})
.catch((error) => {
console.error("Error:", error.message);
})
.finally(() => {
console.log("Operation complete");
});
Promise States
A Promise can be in one of three states:
- Pending: Initial state, neither fulfilled nor rejected
- Fulfilled: The operation completed successfully
- Rejected: The operation failed
Once settled (fulfilled or rejected), a Promise cannot change state.
Chaining Promises
Unlike callbacks, Promises chain cleanly with .then():
fetchUser(1)
.then((user) => fetchOrders(user.id))
.then((orders) => fetchOrderDetails(orders[0].id))
.then((details) => console.log(details))
.catch((error) => console.error(error));
Each .then() returns a new Promise, enabling linear chains.
Promise.all and Promise.race
const urls = [
"https://api.example.com/users",
"https://api.example.com/posts",
"https://api.example.com/comments"
];
// Wait for all promises to resolve
Promise.all(urls.map(fetch))
.then(([users, posts, comments]) => {
console.log("All data loaded:", { users, posts, comments });
})
.catch((error) => {
console.error("One or more requests failed:", error);
});
// Race: whichever resolves first wins
Promise.race([
new Promise((resolve) => setTimeout(resolve, 1000, "slow")),
new Promise((resolve) => setTimeout(resolve, 500, "fast"))
]).then((result) => {
console.log(result); // "fast"
});
Promise.allSettled and Promise.any
// Wait for all promises to settle (fulfill or reject)
Promise.allSettled([
fetch("/api/users"),
fetch("/api/posts"),
fetch("/api/missing")
]).then((results) => {
results.forEach((result, index) => {
if (result.status === "fulfilled") {
console.log("Request " + index + " succeeded:", result.value);
} else {
console.log("Request " + index + " failed:", result.reason);
}
});
});
// Promise.any: first fulfilled wins (ignores rejections)
Promise.any([
fetch("/api/fast"),
fetch("/api/slow"),
fetch("/api/fallback")
]).then((result) => console.log("First success:", result));
async/await
async/await, introduced in ES2017, makes asynchronous code look and behave like synchronous code. It’s syntactic sugar over Promises.
The async Keyword
The async keyword before a function makes it return a Promise:
async function fetchData() {
return { id: 1, name: "JavaScript" };
}
fetchData().then((data) => console.log(data));
The await Keyword
await pauses execution inside an async function until a Promise resolves:
async function getUserData() {
try {
const response = await fetch("https://api.example.com/user/1");
if (!response.ok) {
throw new Error("HTTP error! status: " + response.status);
}
const user = await response.json();
console.log(user);
return user;
} catch (error) {
console.error("Failed to fetch user:", error);
throw error;
}
}
getUserData();
Error Handling with try/catch
With async/await, use try/catch for error handling:
async function example() {
try {
const result = await riskyOperation();
console.log(result);
} catch (error) {
console.error("Something went wrong:", error.message);
} finally {
console.log("Cleanup code runs regardless");
}
}
Parallel Execution with Promise.all
Even though await runs sequentially by default, you can still run operations in parallel:
async function fetchAllData() {
// Wrong: sequential (slow)
const user = await fetchUser();
const posts = await fetchPosts();
const comments = await fetchComments();
// Right: parallel (fast)
const [userRes, postsRes, commentsRes] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
]);
const [user, posts, comments] = await Promise.all([
userRes.json(),
postsRes.json(),
commentsRes.json()
]);
return { user, posts, comments };
}
Sequential vs Parallel Patterns
// Sequential: each waits for the previous
async function sequential() {
const result1 = await operation1();
const result2 = await operation2(result1);
const result3 = await operation3(result2);
return result3;
}
// Parallel: all start together
async function parallel() {
const [result1, result2, result3] = await Promise.all([
operation1(),
operation2(),
operation3()
]);
return { result1, result2, result3 };
}
Choosing the Right Pattern
- Callbacks: Legacy code, simple cases. Avoid nesting.
- Promises: When you need Promise-specific methods like
Promise.all()orPromise.race(). - async/await: Most cases—especially when code readability matters.
Modern JavaScript development favors async/await for its readability, but understanding Promises is essential since many APIs still return them.
Common Pitfalls
Forgetting to await:
async function broken() {
fetch("/api/data"); // Promise ignored!
// Code here runs before fetch completes
}
async function fixed() {
await fetch("/api/data");
// Code here runs after fetch completes
}
Not handling rejections:
// Unhandled rejection warning
async function danger() {
return fetch("/api/data"); // If this fails, no .catch()!
}
async function safe() {
try {
return await fetch("/api/data");
} catch (e) {
return null;
}
}
Summary
JavaScript’s async capabilities have evolved significantly:
- Callbacks are the foundation but lead to nested code
- Promises enable chaining and better error handling
- async/await provides synchronous-looking code with async benefits
Master all three patterns to handle real-world JavaScript development confidently.