Async/Await in JavaScript: Writing Cleaner Asynchronous Code

Promises solved callback hell. Async/await made promises look like normal code.
The Problem That Needed Solving
If you've read about callbacks and promises, you know the story. JavaScript is single-threaded, it can only do one thing at a time. But real applications need to wait for things: API responses, file reads, database queries. You can't just pause and wait, so JavaScript handles these things asynchronously.
Callbacks were the first solution. They worked, but they nested badly. Promises cleaned that up significantly. But even with promises, the code had a certain mechanical quality to it, chains of .then() and .catch() that required you to think in a different mental mode.
Look at this promise chain fetching a user and their posts:
function loadUserData(userId) {
fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(user => {
return fetch(`/api/posts?userId=${user.id}`)
.then(response => response.json())
.then(posts => {
return { user, posts };
});
})
.then(data => {
console.log("Got everything:", data);
})
.catch(error => {
console.error("Something failed:", error.message);
});
}
It's not terrible. But it's also not natural. You're constantly passing functions into .then(), thinking in terms of chains, managing nesting when operations depend on each other. It's not how you'd describe the steps out loud.
Out loud, you'd say: "Get the user. Then use their ID to get their posts. Then show both."
That's exactly what async/await lets you write.
What Async/Await Actually Is
Async/await is syntactic sugar over promises. It doesn't replace promises, it's built entirely on top of them. Under the hood, an async function returns a promise. The await keyword pauses execution inside that function until a promise settles.
No new async mechanism. No new engine feature. Just a cleaner way to write the same promise-based code.
Think of it this way: promises are the engine, async/await is the steering wheel. You're driving the same car, it just feels much more natural.
The async Keyword
Putting async before a function declaration does one thing: it makes the function always return a promise.
async function greet() {
return "Hello!";
}
greet(); // Returns a Promise, not "Hello!" directly
greet().then(value => console.log(value)); // "Hello!"
Even though the function just returns a plain string, wrapping it in async automatically wraps that return value in a resolved promise.
You can write async functions in any style you normally would:
// Function declaration
async function fetchUser(id) { ... }
// Arrow function
const fetchUser = async (id) => { ... }
// Method in an object
const api = {
async getUser(id) { ... }
};
// Class method
class UserService {
async find(id) { ... }
}
All of these behave the same way, they return promises, and they can use await inside them.
The await Keyword
await is the other half. You can only use it inside an async function, and it does something simple: it pauses the function until the promise it's waiting on resolves, then gives you the resolved value.
async function loadUser(id) {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
return user;
}
Step by step, what happens here:
1. fetch() is called, returns a promise
2. await pauses loadUser() until the fetch resolves
3. response holds the actual Response object (not a promise)
4. response.json() is called, returns another promise
5. await pauses again until the JSON parsing is done
6. user holds the actual parsed object
7. user is returned, wrapped in a promise automatically
The key thing: await unwraps the promise. You get the value directly, no .then(), no callback, just the value sitting in a variable.
Now rewrite the earlier example:
// Promises version
function loadUserData(userId) {
fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(user => {
return fetch(`/api/posts?userId=${user.id}`)
.then(response => response.json())
.then(posts => ({ user, posts }));
})
.then(data => console.log("Got everything:", data))
.catch(error => console.error("Something failed:", error.message));
}
// Async/await version
async function loadUserData(userId) {
const userResponse = await fetch(`/api/users/${userId}`);
const user = await userResponse.json();
const postsResponse = await fetch(`/api/posts?userId=${user.id}`);
const posts = await postsResponse.json();
console.log("Got everything:", { user, posts });
}
The logic is identical. The async/await version reads top to bottom, step by step. You don't need to mentally track .then() chains or think about what's in scope at each level. It looks like synchronous code — and that's the entire point.
Error Handling with Async/Await
With promises, you handle errors using .catch(). With async/await, you use the same try/catch you'd use for any synchronous code. This is one of the biggest readability wins.
async function loadUserData(userId) {
try {
const userResponse = await fetch(`/api/users/${userId}`);
const user = await userResponse.json();
const postsResponse = await fetch(`/api/posts?userId=${user.id}`);
const posts = await postsResponse.json();
return { user, posts };
} catch (error) {
console.error("Failed to load user data:", error.message);
return null;
}
}
The try block contains all the steps. If anything fails, the network request, the JSON parsing, anything, execution jumps to catch. One error handler covers all the steps. Clean, readable, consistent with how you handle errors everywhere else in JavaScript.
Handling Different Errors Differently
Sometimes you want to distinguish between types of failure, a network error versus a "user not found" response:
async function getUser(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`User not found (status: ${response.status})`);
}
const user = await response.json();
return user;
} catch (error) {
if (error.message.includes("Failed to fetch")) {
console.error("Network problem — check your connection.");
} else {
console.error("Could not get user:", error.message);
}
return null;
}
}
A network failure throws a native fetch error. A 404 or 500 response doesn't automatically throw, you check response.ok and throw your own error if needed. Both end up in the same catch.
Running Multiple Things at the Same Time
One mistake people make with async/await is using it sequentially when operations could run in parallel.
// Slow — these run one after the other
async function loadDashboard(userId) {
const user = await fetchUser(userId); // Wait for this...
const posts = await fetchPosts(userId); // Then wait for this...
const alerts = await fetchAlerts(userId); // Then wait for this...
return { user, posts, alerts };
}
If each request takes 1 second, this takes 3 seconds total, even though none of these requests depend on each other.
// Fast — all three run at the same time
async function loadDashboard(userId) {
const [user, posts, alerts] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchAlerts(userId)
]);
return { user, posts, alerts };
}
Promise.all() kicks off all three requests simultaneously and waits until all of them finish. If they each take 1 second, this takes 1 second total, not 3.
The rule is simple: if later steps don't depend on earlier results, run them together with Promise.all(). If they do depend on each other, await them in sequence.
Async/Await vs Promises: Side by Side
Here's the same operation written both ways, so you can see the difference clearly:
// Scenario: fetch a product, then fetch reviews for that product
// ── With Promises ──────────────────────────────────────
function loadProduct(id) {
return fetch(`/api/products/${id}`)
.then(res => res.json())
.then(product => {
return fetch(`/api/reviews?productId=${product.id}`)
.then(res => res.json())
.then(reviews => ({ product, reviews }));
})
.catch(err => {
console.error("Error:", err.message);
return null;
});
}
// ── With Async/Await ───────────────────────────────────
async function loadProduct(id) {
try {
const res1 = await fetch(`/api/products/${id}`);
const product = await res1.json();
const res2 = await fetch(`/api/reviews?productId=${product.id}`);
const reviews = await res2.json();
return { product, reviews };
} catch (err) {
console.error("Error:", err.message);
return null;
}
}
Both do the same thing. The promise version works fine. But the async/await version is just easier to read, especially when you're returning to it three weeks later at 11 PM trying to find a bug.
A Few Things Worth Knowing
await only works inside async functions. You can't use it at the top level of a script in most environments — though modern JavaScript (ES2022+) supports top-level await in modules.
// This works in modules (top-level await)
const user = await fetchUser(1);
// This doesn't work in regular scripts
// SyntaxError: await is only valid in async functions
An async function always returns a promise, even if you return a plain value or nothing at all.
async function nothing() {}
nothing(); // Promise { undefined }
async function something() { return 42; }
something(); // Promise { 42 }
Forgetting await is a common mistake. Without it, you get a promise object instead of the actual value:
async function example() {
const user = fetch("/api/user"); // ← forgot await!
console.log(user.name); // undefined — user is a Promise, not an object
}
This is one of the most common bugs with async/await. If something is undefined when it shouldn't be, check whether you forgot await.
When to Use Async/Await
Use it for almost any promise-based code. The cases where you might stick with raw promise syntax are fairly specific, like when you need to attach .finally() in a particular way, or when you're building a utility that chains promises dynamically. For day-to-day work, async/await is almost always clearer.
// Fetching data
const data = await fetch(url).then(r => r.json());
// Reading files (Node.js)
const contents = await fs.promises.readFile("file.txt", "utf8");
// Database queries
const users = await db.query("SELECT * FROM users");
// Waiting for a timeout
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
await sleep(1000); // pause for 1 second
Key Takeaways
Async/await is syntactic sugar over promises, it doesn't replace them, it makes them easier to write and read.
An
asyncfunction always returns a promise, whether you return a plain value or not.awaitpauses the function until a promise resolves, and gives you the resolved value directly, no.then()needed.Error handling uses plain
try/catch— the same pattern you use for synchronous code.Always check
response.okin fetch calls, a 404 or 500 won't automatically throw.Use
Promise.all()when multiple async operations don't depend on each other, run them in parallel, not sequence.Forgetting
awaitis the most common bug, if a value looks like a Promise instead of what you expected, that's why.
Quick Reference
// Define an async function
async function fetchData() { ... }
const fetchData = async () => { ... }
// Await a promise
const result = await somePromise();
// Error handling
async function safeOperation() {
try {
const data = await riskyCall();
return data;
} catch (error) {
console.error(error.message);
return null;
}
}
// Parallel requests
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
// Check response before parsing
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
const data = await response.json();
// Common mistake — missing await
const data = fetch(url); // data is a Promise
const data = await fetch(url); // data is a Response
Async/await didn't change how JavaScript handles time. It just made the code stop pretending it was complicated.

