JavaScript is single-threaded. It runs one line of code at a time on the main thread, and if that line takes too long, everything else waits. This is the fundamental constraint that makes asynchronous programming not just useful, but necessary.
Think about fetching data from an API, reading a file, or waiting for a timer. If the language stopped everything while those operations completed, your web app would freeze on every network request. Users would stare at a blank screen while the browser waited for a response.
Thankfully, JavaScript has a solution. But the path to that solution has been a winding one.
A Brief History
The original approach was the callback. You pass a function as an argument, and it runs when the async operation finishes. This works for simple cases but falls apart fast.
getUserData(userId, function(user) {
getPosts(user.id, function(posts) {
getComments(posts[0].id, function(comments) {
console.log(comments);
});
});
});
This is callback hell. The code grows rightward instead of downward. Error handling is inconsistent. Tracking the flow of logic becomes a debugging nightmare.
Promises arrived to fix this. They let you chain asynchronous operations in a flat structure.
getUserData(userId)
.then(function(user) {
return getPosts(user.id);
})
.then(function(posts) {
return getComments(posts[0].id);
})
.then(function(comments) {
console.log(comments);
})
.catch(function(error) {
console.error(error);
});
Better, but still noisy. Every step needs a .then() and a function wrapper. The logic is linear, but the syntax is not.
Then came async and await in ES2017.
async function showComments(userId) {
try {
const user = await getUserData(userId);
const posts = await getPosts(user.id);
const comments = await getComments(posts[0].id);
console.log(comments);
} catch (error) {
console.error(error);
}
}
This reads like synchronous code. The await keyword pauses the function until the promise resolves, then unwraps the value. The async keyword marks the function as asynchronous so it can use await internally. Together, they turn a chain of callbacks into a clean, readable block of code.
Error Handling with Try/Catch
One of the best features of async/await is that it brings back try/catch. No more adding .catch() at the end of every chain. You wrap your logic in a try block and handle errors in one place.
async function fetchUserProfile(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to fetch user profile:", error);
return null;
}
}
A common question is whether try/catch with async/await replaces
.catch()on promises entirely. The answer is yes, for most cases. But you should know that both exist, because you will encounter promise chains in older codebases. Understanding both patterns is the mark of a well-rounded JavaScript developer.
Running Things in Parallel
Async/await makes sequential code easy, but that can lead to a subtle performance trap. Consider this:
async function loadDashboard() {
const user = await fetchUser();
const posts = await fetchPosts();
const notifications = await fetchNotifications();
// Each request waits for the previous one to finish
}
These three requests do not depend on each other. Running them one after another wastes time. The total wait time is the sum of all three requests.
The fix is Promise.all, which runs promises in parallel:
async function loadDashboard() {
const [user, posts, notifications] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchNotifications()
]);
// All three requests run simultaneously
}
Now the total wait time is only as long as the slowest request. On a typical web app, that can cut load times by half or more.
Common Pitfalls
Async/await looks like synchronous code, but it is not. Here are the mistakes I see most often.
Forgetting await. This is the number one mistake. If you forget await, you get a promise instead of the resolved value. The code will not error, but it will not work either. Your variable will be a pending promise object, and every operation on it will silently fail.
Using await in a non-async function. The await keyword only works inside functions declared with async. If you try to use it in a regular function, JavaScript throws a syntax error. Always check that the enclosing function has the async keyword.
Awaiting sequential when parallel is possible. As shown above, this is a performance killer. If your await statements do not depend on each other, wrap them in Promise.all.
Forgetting that async functions return a promise. Even if your function does not use await, an async function always returns a promise. Callers need to handle it with await or .then().
Wrapping Up
Async/await is not a new concept. Other languages have had similar constructs for years. But its arrival in JavaScript was a genuine improvement. It made asynchronous code easier to write, easier to read, and easier to debug.
If you are still using raw promises everywhere, that is fine. There is nothing wrong with .then(). But give async/await a real try on your next project. Write a controller, a data fetcher, or a utility function with it. The readability gains are immediate.
Start with the simple stuff. Add error handling. Then optimize for parallelism when you need it. Async/await rewards that progression. It grows with you.