JavaScript Callbacks, Promises, and async/await: A Practical Guide
Asynchronous programming is essential in JavaScript, and you will encounter callbacks, promises, and async/await in nearly every real project. 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. This pattern works fine for a single async operation, but chaining multiple asynchronous steps quickly becomes unwieldy.
Callback Hell
When you have multiple dependent async operations, callbacks nest deeper and deeper. Each operation passes its result to the next callback, creating a pyramid of indentation:
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.all fails fast — if any promise rejects, the entire result rejects. For scenarios where you want to collect both successes and failures, or where you want the first successful result, Promise provides two additional combinators:
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 that lets you write async logic as if it were sequential, while still running non-blocking operations.
The async Keyword
The async keyword before a function makes it return a Promise automatically. Even if you return a plain value, it gets wrapped:
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. This lets you write async code that reads top-to-bottom, with each line waiting for the previous one to finish:
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. This keeps error handling close to the code that might fail, without scattering .catch() callbacks across the function body:
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 by starting promises before awaiting them. The trick is to fire off all promises first, then await Promise.all to collect the results:
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
Choosing between sequential and parallel execution is one of the most important async design decisions. Sequential runs each operation one after another, while parallel starts all of them at the same time:
// 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, results arrive when ready
async function parallel() {
const [result1, result2, result3] = await Promise.all([
operation1(),
operation2(),
operation3()
]);
return { result1, result2, result3 };
}
The choice depends on whether operations depend on each other’s output. If operation2 needs output from operation1, you must run them sequentially. If they are independent, Promise.all runs them concurrently and finishes when the slowest one completes.
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 continues immediately
// Code here runs before fetch completes
}
async function fixed() {
await fetch("/api/data");
// Code here runs after fetch completes
}
Not handling rejections leaves the promise dangling, which can cause unhandled rejection warnings in Node.js or silent failures in the browser:
// 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
Choosing the right async style
Callbacks, Promises, and async/await are not separate islands. They are different ways of expressing the same underlying idea: work happens now, and the result arrives later. Callbacks are the most direct shape, but they can become hard to read once operations depend on each other. Promises help by making the chain flatter and by giving you a cleaner place for shared error handling. async/await keeps that promise-based behavior while making the flow look closer to normal sequential code.
That progression is useful because the right style depends on the shape of the task. A small event handler may be fine with a callback, a utility that combines several steps may feel better with promises, and a larger feature with several dependent operations often reads best with async/await. The goal is not to force every feature into one pattern. The goal is to pick the one that makes the flow easiest to understand and the errors easiest to handle.
Error paths matter
Async code is often written for the happy path first, but the failure path is where many bugs show up. A request can fail, a timer can be cleared early, or a promise can reject because a dependency was unavailable. Good async code makes those cases visible instead of pretending they do not exist. That means using catch, try/catch, or error-first callbacks consistently and deciding what the caller should do when something goes wrong.
It also helps to keep cleanup close to the code that starts the work. If a callback registers a listener or an async function opens a resource, the code should also show how that resource is released. The more clearly the error and cleanup paths are written, the less likely the async code is to surprise you later. That clarity matters even more when several operations depend on each other.
Sequencing And Parallelism
One of the biggest gains from async/await is that it lets you choose between sequential and parallel work more clearly. Some tasks must happen in order, but others can run at the same time. When you start asking that question deliberately, the code usually gets faster and easier to read. Promise.all is a good example because it makes parallel work obvious at the call site instead of hiding it in nested chains.
The same idea applies to callbacks and plain promises. If the operations do not depend on one another, there is no reason to wait for them one by one. If they do depend on one another, the code should make that dependency plain. Clear sequencing is one of the easiest ways to make async code feel dependable instead of mysterious.
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.
Final async note
The main habit to keep is simple: make the order of work obvious. If something must happen later, say that with the pattern you choose. If several things can happen together, make that parallelism visible. Clear async flow is one of the easiest ways to keep a feature understandable.
That clarity also makes debugging easier because the order of operations is visible in the code itself instead of hidden in nested timing behavior.