Async/Await in JavaScript: Writing Cleaner Asynchronous Code
Async/await is syntactic sugar over Promises that makes async code read like synchronous code. It was introduced in ES2017 to solve the readability problems of nested
.then()chains and callback hell. Error handling becomes a plaintry/catchblock.
Audience: Assumes basic knowledge of JavaScript functions and callbacks.
Problem
JavaScript is single-threaded. Every I/O operation — reading a file, querying a database, calling an API — is asynchronous. For years, we handled this with callbacks. That worked until logic became complex:
// callback-hell.js
getUser('user_42', (err, user) => {
if (err) throw err;
getUserOrders(user.id, (err, orders) => {
if (err) throw err;
getOrderItems(orders[0].id, (err, items) => {
if (err) throw err;
console.log(items); // finally.
});
});
});
Each nested level adds indentation, repeats error handling, and makes sequential logic look like a tree. Promises improved this — but chained .then() calls still don't read like normal code.
Why async/await Was Introduced
In ES2017, JavaScript introduced async/await as first-class syntax for working with Promises. The goal was simple: make async code look synchronous without blocking the event loop.
Key insight: async/await doesn't add new capabilities. Under the hood, an
asyncfunction always returns a Promise.awaitis just a way to pause execution inside that function until a Promise resolves — without blocking other work.
Async function execution flow
Call async fn await I/O Promise resolves
(returns Promise) → (fn pauses) → (fn resumes) → ...
↕
Event loop runs other callbacks
The function pauses at await but the thread stays free — other work continues in the meantime.
How async Functions Work
Any function prefixed with async implicitly wraps its return value in a Promise.resolve(). This means callers always get a Promise, whether or not you explicitly return one.
// async-basics.js
// Both functions are equivalent
function withPromise() {
return Promise.resolve(42);
}
async function withAsync() {
return 42; // auto-wrapped in Promise.resolve()
}
withAsync().then(console.log); // 42
// You can also await it
const val = await withAsync(); // 42
The await Keyword
await can only be used inside an async function (or at the top level of ES modules). It pauses the function at that line until the awaited Promise settles.
// fetch-user.js
async function fetchUserProfile(userId) {
// Each await pauses execution until that line resolves
const response = await fetch(`/api/users/${userId}`);
const user = await response.json();
const orders = await fetch(`/api/orders?userId=${user.id}`);
const orderData = await orders.json();
return { user, orders: orderData };
}
// Usage — reads like synchronous code
const profile = await fetchUserProfile('user_42');
console.log(profile.user.name); // "Alice"
Compare this to the equivalent Promise chain — same logic, much harder to follow:
// fetch-user-promises.js
function fetchUserProfile(userId) {
return fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(user => {
return fetch(`/api/orders?userId=${user.id}`)
.then(res => res.json())
.then(orders => ({ user, orders })); // note: closure required for user
});
}
Note the closure needed to keep user in scope — a common source of bugs. async/await eliminates this entirely.
Error Handling
Rejected Promises become throwable errors inside async functions, so try/catch handles both sync and async errors in the same block.
// error-handling.js
async function loadDashboard(userId) {
try {
const user = await getUser(userId); // may reject
const perms = await getPermissions(user.id); // may reject
if (!perms.includes('dashboard')) {
throw new Error('Access denied'); // sync throw — same catch block
}
return await buildDashboard(user);
} catch (err) {
console.error('Dashboard load failed:', err.message);
throw err; // re-throw so caller knows something failed
}
}
// Output on permission error:
// Dashboard load failed: Access denied
Common mistake: Forgetting to
awaita Promise means the catch block never fires for that rejection.const data = fetchData()gives you a Promise, not the data. Always await or handle the returned Promise.
Parallel Execution with Promise.all
Sequential await calls run one at a time. If two operations don't depend on each other, run them in parallel using Promise.all:
// parallel-fetch.js
// Sequential — takes 600ms (200ms + 400ms)
async function sequentialLoad(userId) {
const user = await getUser(userId); // 200ms
const settings = await getSettings(userId); // 400ms
return { user, settings };
}
// Parallel — takes ~400ms (max of the two)
async function parallelLoad(userId) {
const [user, settings] = await Promise.all([
getUser(userId),
getSettings(userId),
]);
return { user, settings };
}
// 33% faster — both requests fire simultaneously
Comparison: Promises vs async/await
| Dimension | Promises (.then) | async/await |
|---|---|---|
| Readability | Chaining is OK for simple flows; breaks down with branching logic | Reads linearly — sequential async code looks synchronous |
| Error handling | Requires .catch() at the end of the chain; easy to miss |
Standard try/catch — works for both sync and async errors |
| Debugging | Stack traces can be shallow; callbacks may not show caller context | Better stack traces; breakpoints work on await lines |
| Parallel execution | Promise.all() built for this |
Use await Promise.all() — same underlying mechanism |
| Conditionals | Nested .then() or explicit early returns required |
Plain if/else inside the function |
| Underlying runtime | Identical — async/await compiles to Promises | Identical — async/await compiles to Promises |
Trade-offs
Strengths:
Reads like synchronous code
try/catchhandles sync and async errors in one blockEasier debugging with meaningful stack traces
Loops and conditionals work naturally
Limitations:
Sequential awaits miss parallelism opportunities
Must be used inside an
asyncfunction (except ES module top level)Unhandled rejections can fail silently if
.catch()is omitted on the outer PromiseAsync functions always return Promises — callers must handle that
When NOT to use await sequentially: If you write
await a(); await b(); await c()and none of those depend on each other, you've accidentally made parallel work sequential. UsePromise.all()when there's no data dependency between calls.
Conclusion
async/await doesn't change what JavaScript can do — it changes how async code looks and feels. The main win is that error handling, branching, and sequential logic all work the way you already know, without learning a new API.
The mental model: async marks a function as Promise-returning. await pauses that function until a Promise settles. Everything else follows from that.
Next step: explore Promise.allSettled() for cases where you want all results regardless of failures, and AbortController for cancellable async operations.
Further Reading
MDN — async function — Authoritative reference with full spec details and edge cases
javascript.info — Async/await — Thorough walkthrough with interactive examples
V8 Blog — Faster async functions and promises — How the V8 engine optimizes async/await at the runtime level
MDN — Using Promises — Understanding the underlying Promise model helps write better async code
web.dev — JavaScript Promises: an introduction — Jake Archibald's foundational explainer on the Promise design


