Skip to main content
Madhukar
All Articles

Async/Await in JavaScript: Writing Cleaner Asynchronous Code

August 10, 20268 min read
JavaScriptAsynchronous ProgrammingNode.jsWeb DevelopmentProgramming
Async/Await in JavaScript: Writing Cleaner Asynchronous Code

Why Async/Await Was Introduced

Promises solved callback hell — but chaining several dependent asynchronous steps with .then() still doesn't read quite like ordinary code:

getUser(id)
.then(user => getOrders(user.id))
.then(orders => getReceipts(orders))
.then(receipts => {
console.log(receipts);
})
.catch(err => handleError(err));

This works, and it’s a real improvement over nested callbacks — but it’s still not quite as easy to read as a plain, linear sequence of steps. async/await, introduced in ES2017, was built specifically to close that remaining gap — letting asynchronous code be written so it reads almost exactly like ordinary, synchronous, top-to-bottom code.

async function loadData(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
const receipts = await getReceipts(orders);
console.log(receipts);
} catch (err) {
handleError(err);
}
}

Same behavior, dramatically more readable — no .then() chain to visually trace, just a sequence of steps read from top to bottom.

Important framing: async/await doesn't replace promises — it's syntactic sugar built directly on top of them. Underneath, it's still promises doing the actual asynchronous work; async/await just changes how that work is written and read.

How Async Functions Work

Adding the async keyword before a function declaration changes two things about it:

async function getUser(id) {
return { id, name: "Aarav" };
}

An async function always returns a promise

Even though getUser above just returns a plain object, calling it returns a promise that resolves with that object:

getUser(1).then(user => console.log(user));
// { id: 1, name: "Aarav" }

An async function can use await inside it

The await keyword can be used inside an async function. Modern JavaScript also supports top-level await in modules.

Await Keyword Concept

await pauses the execution of the surrounding async function until the awaited value settles. If the promise fulfills, await produces its value; if it rejects, await throws the rejection reason— without blocking the rest of the application.

async function loadUser() {
console.log("Fetching user...");
const user = await getUser(1); // pauses here until getUser's promise settles
console.log("Got user:", user);
}
Fetching user...
Got user: { id: 1, name: "Aarav" }

What “pausing” actually means here

await doesn't freeze the entire program — it pauses just that specific async function's execution, at that exact line, while the rest of the application (other code, other requests) continues running normally in the meantime. Once the awaited promise settles, execution resumes exactly where it left off, with the resolved value assigned to the variable.

Multiple sequential awaits

async function loadDashboard(id) {
const user = await getUser(id);
const orders = await getOrders(user.id);
const receipts = await getReceipts(orders);
return receipts;
}

Each await waits for its own step to finish before the next line runs — producing the exact same sequential behavior as a chained .then(), just without the chain itself visually present.

Sequential vs Parallel Awaits

Not every asynchronous operation depends on the previous one. When operations are independent, waiting for them one by one can unnecessarily increase the total execution time.

async function loadDashboard() {
const user = await getUser();
const notifications = await getNotifications();
const recommendations = await getRecommendations();

return { user, notifications, recommendations };
}

Run Independent Operations Concurrently

Since these operations are independent, they can be started together using Promise.all():

async function loadDashboard() {
const [user, notifications, recommendations] = await Promise.all([
getUser(),
getNotifications(),
getRecommendations()
]);

return { user, notifications, recommendations };
}

Promise.all() starts all three operations concurrently and waits for all of them to settle. This avoids unnecessary sequential waiting when the operations do not depend on each other.

Error Handling With Async Code

Instead of .catch(), async/await uses the familiar try/catch block covered elsewhere in this series — a genuinely major readability win, since it means asynchronous and synchronous error handling finally look the same.

async function loadUser(id) {
try {
const user = await getUser(id);
console.log(user);
} catch (err) {
console.log("Failed to load user:", err.message);
}
}

If getUser(id)'s promise rejects, execution jumps straight to the catch block — exactly like a thrown error in ordinary synchronous code, covered in this series's error-handling article.

Handling errors from multiple awaited steps

async function loadDashboard(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
const receipts = await getReceipts(orders);
return receipts;
} catch (err) {
console.log("Something failed in the dashboard load:", err.message);
}
}

A single try/catch covers every await inside it — if any step fails, execution jumps directly to catch, without needing a separate error handler for each individual step, the same centralizing benefit .catch() provided for promise chains.

Comparison With Promises

// Promises
function loadDashboard(id) {
return getUser(id)
.then(user => getOrders(user.id))
.then(orders => getReceipts(orders))
.catch(err => console.log("Failed:", err.message));
}

// async/await
async function loadDashboard(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
return await getReceipts(orders);
} catch (err) {
console.log("Failed:", err.message);
}
}

Neither one changes what’s actually happening asynchronously underneath — the choice is entirely about which one is easier for a human to read and maintain, and for most multi-step asynchronous logic, async/await wins clearly on that front.

Final Takeaway

async/await doesn't introduce a new way for JavaScript to handle asynchronous work — promises are still doing exactly the same job underneath. What changes is how that work gets written: a sequence of awaited steps, read top to bottom, with familiar try/catch for errors, instead of a chain of .then() callbacks. For any asynchronous logic involving more than one dependent step, that readability difference adds up quickly — which is one reason async/await has become a common and preferred way to write asynchronous JavaScript. However, good asynchronous code also requires understanding which operations are dependent and which can safely run concurrently.

Frequently Asked Questions

Does await block the entire application while waiting?

> No — it only pauses execution within that specific async function. The rest of the application, including other requests or other code, continues running normally while that one function waits for its awaited promise to settle.

Can I use await outside of an async function?

> Not in regular functions — await is only valid inside a function declared with async (with one modern exception: top-level await in certain module contexts, a more advanced case worth learning about separately).

Is async/await faster than using promises directly?

> No — since async/await is built on top of promises, the actual underlying performance is identical. The benefit is purely in code readability and maintainability, not execution speed.

What happens if I forget to use try/catch with async/await?

> An unhandled rejected promise inside an async function without a try/catch will cause that returned promise to reject, and depending on your environment, may produce an unhandled promise rejection warning or error — using try/catch (or attaching a .catch() where the async function is called) is the reliable way to handle this properly.

Originally published by Mr Madhukar

Read the complete article on Medium with full formatting & reader responses.