Async/Await in JavaScript: Writing Asynchronous Code That Looks Synchronous
JavaScript's asynchronous evolution moved from callbacks to promises to async/await — each step making async code more readable and maintainable. Async/await, introduced in ES2017 (ES8), is not a new concept or a replacement for promises. It is syntactic sugar — a cleaner way to write promise-based code that reads like synchronous code while preserving all of JavaScript's non-blocking behavior. This guide explains why it was introduced, how it works under the hood, and how it transforms the way you write asynchronous JavaScript.
Why Async/Await Was Introduced
The Promise Readability Problem
Promises solved callback hell, but they introduced their own readability challenges. Complex promise chains become verbose, and the mental model of chaining .then() calls is not intuitive for developers coming from synchronous languages.
The Promise Chain Complexity
function getUserData(userId) {
return fetchUser(userId)
.then(user => {
console.log("User found:", user.name);
return fetchOrders(user.id);
})
.then(orders => {
console.log("Orders found:", orders.length);
return fetchShippingStatus(orders[0].id);
})
.then(status => {
console.log("Shipping status:", status);
return { user, orders, status }; // Wait — 'user' is not in scope here!
})
.catch(error => {
console.error("Something failed:", error);
});
}
The pain points:
- Variables from earlier
.then()blocks are not accessible in later blocks - Scoping issues force you to declare outer variables or nest promises
- Error handling with
.catch()catches all errors in the chain, making it hard to handle specific step failures - The visual flow is vertical but the execution flow is fragmented
- Debugging is difficult because breakpoints behave unexpectedly across
.then()boundaries
The Mental Overhead
Reading promise chains requires holding a mental stack of "what resolves to what." Each .then() creates a new scope. Each return value flows to the next link. For simple cases, this is fine. For business logic with conditionals, loops, and parallel operations, the cognitive load becomes significant.
What Developers Wanted
Developers wanted to write asynchronous code that:
- Reads top-to-bottom like synchronous code
- Uses familiar control flow (
if,for,try/catch) - Allows variables to remain in scope across multiple async operations
- Can be debugged with standard breakpoints
- Does not require learning a new pattern (promises) on top of another pattern (callbacks)
Async/await delivers exactly this. It does not replace promises — it makes promises readable.
How Async Functions Work
The async Keyword
Placing async before a function declaration does two things:
- It makes the function always return a promise
-
It allows the use of
awaitinside the function
// A regular function returns its value directly
function regularFunction() {
return "Hello";
}
const result1 = regularFunction();
console.log(result1); // "Hello"
// An async function wraps its return value in a promise
async function asyncFunction() {
return "Hello";
}
const result2 = asyncFunction();
console.log(result2); // Promise { 'Hello' }
result2.then(value => console.log(value)); // "Hello"
Key insight: An async function automatically wraps its return value in a resolved promise. If you return a promise, it is returned as-is. If you throw an error, it returns a rejected promise.
What Happens Under the Hood
When JavaScript encounters an async function, the engine transforms it internally into a state machine that uses promises. The code you write:
async function fetchData() {
const response = await fetch('/api/data');
const data = await response.json();
return data;
}
Is conceptually equivalent to:
function fetchData() {
return fetch('/api/data')
.then(response => response.json())
.then(data => data);
}
The async/await syntax is syntactic sugar — it compiles to promise-based code. The engine handles the .then() chaining for you, but the underlying mechanism is identical.
Async Functions Always Return Promises
async function returnValue() {
return 42; // Returns Promise.resolve(42)
}
async function returnPromise() {
return fetch('/api/data'); // Returns the promise directly
}
async function throwError() {
throw new Error("Something broke"); // Returns Promise.reject(Error)
}
// All three return promises
console.log(returnValue()); // Promise { 42 }
console.log(returnPromise()); // Promise { <pending> }
console.log(throwError()); // Promise { <rejected> Error }
Await Keyword Concept
What await Does
The await keyword can only be used inside an async function. It pauses the execution of the async function until the awaited promise settles, then returns the resolved value.
Critical distinction: await does not block the main thread. It only pauses the current async function. Other JavaScript code continues running normally.
async function makeBreakfast() {
console.log("1. Start coffee");
const coffee = await brewCoffee(); // Pauses here, but main thread is FREE
console.log("2. Coffee ready:", coffee);
const toast = await makeToast(); // Pauses again, main thread still FREE
console.log("3. Toast ready:", toast);
return "Breakfast complete!";
}
// Main thread continues immediately
console.log("4. Called makeBreakfast");
makeBreakfast().then(result => console.log("5.", result));
console.log("6. Main thread keeps going");
// Output:
// 4. Called makeBreakfast
// 1. Start coffee
// 6. Main thread keeps going
// [coffee brews in background]
// 2. Coffee ready: Dark roast
// [toast toasts in background]
// 3. Toast ready: Golden brown
// 5. Breakfast complete!
What happened:
- Line 4 prints immediately
-
makeBreakfast()starts, prints line 1 -
await brewCoffee()pauses the function but not the main thread - Line 6 prints while coffee is still brewing
- When coffee resolves, line 2 prints
-
await makeToast()pauses again - When toast resolves, line 3 prints
- The function returns,
.then()fires, line 5 prints
Await with Non-Promise Values
await accepts any value. If the value is not a promise, it is wrapped in a resolved promise automatically:
async function example() {
const a = await 42; // Equivalent to await Promise.resolve(42)
const b = await "hello"; // Equivalent to await Promise.resolve("hello")
const c = await Promise.resolve(true);
console.log(a, b, c); // 42 "hello" true
}
This is rarely useful but demonstrates that await is forgiving — it always expects a promise-like value and handles non-promises gracefully.
Sequential vs Parallel Await
Sequential (one after another — total time adds up):
async function sequential() {
const user = await fetchUser(); // 100ms
const orders = await fetchOrders(); // 200ms
const stats = await fetchStats(); // 150ms
return { user, orders, stats };
// Total: 450ms
}
Parallel (all at once — total time is the slowest):
async function parallel() {
// Start all three immediately, wait for all to finish
const [user, orders, stats] = await Promise.all([
fetchUser(), // 100ms
fetchOrders(), // 200ms
fetchStats() // 150ms
]);
return { user, orders, stats };
// Total: 200ms (determined by slowest)
}
Key insight: await is sequential by default. Use Promise.all() when operations are independent and can run concurrently.
Error Handling with Async Code
The try/catch Revival
Before async/await, promise error handling used .catch():
fetchUser(123)
.then(user => fetchOrders(user.id))
.then(orders => processOrders(orders))
.catch(error => {
// Catches ANY error in the chain
// But which step failed? Hard to tell.
console.error(error);
});
Async/await brings back familiar try/catch syntax with precise error control:
async function loadDashboard(userId) {
try {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id);
const stats = await fetchStats(user.id);
return { user, orders, stats };
} catch (error) {
// Catches errors from ANY of the three awaits
console.error("Dashboard load failed:", error.message);
return { error: true, message: error.message };
}
}
Granular Error Handling
You can wrap individual await calls for specific error handling:
async function loadDashboard(userId) {
let user, orders, stats;
try {
user = await fetchUser(userId);
} catch (error) {
console.error("Failed to load user:", error.message);
return { error: "User not found" };
}
try {
orders = await fetchOrders(user.id);
} catch (error) {
console.error("Failed to load orders:", error.message);
orders = []; // Graceful fallback
}
try {
stats = await fetchStats(user.id);
} catch (error) {
console.error("Failed to load stats:", error.message);
stats = null; // Graceful fallback
}
return { user, orders, stats };
}
Throwing Errors in Async Functions
async function validateUser(userId) {
const user = await fetchUser(userId);
if (!user) {
throw new Error(`User ${userId} not found`);
}
if (!user.isActive) {
throw new Error(`User ${userId} is suspended`);
}
return user;
}
// The thrown error becomes a rejected promise
try {
const user = await validateUser(999);
} catch (error) {
console.error(error.message); // "User 999 not found"
}
Comparison with Promises
The Same Logic, Three Ways
Task: Fetch a user, then their orders, then return combined data.
Callbacks (The Old Way)
function getUserWithOrders(userId, callback) {
fetchUser(userId, (err, user) => {
if (err) return callback(err);
fetchOrders(user.id, (err, orders) => {
if (err) return callback(err);
callback(null, { user, orders });
});
});
}
Lines: 10 | Readability: Poor | Error handling: Repetitive | Debugging: Difficult
Promises (The Middle Way)
function getUserWithOrders(userId) {
return fetchUser(userId)
.then(user => {
return fetchOrders(user.id)
.then(orders => ({ user, orders }));
});
}
Lines: 7 | Readability: Good | Error handling: Centralized .catch() | Debugging: Moderate
Async/Await (The Modern Way)
async function getUserWithOrders(userId) {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id);
return { user, orders };
}
Lines: 5 | Readability: Excellent | Error handling: try/catch | Debugging: Easy (step through line by line)
Side-by-Side Comparison
| Aspect | Promises | Async/Await |
|---|---|---|
| Syntax | Chain of .then() calls |
Looks like synchronous code |
| Variable scope | New scope per .then() block |
Single scope, variables persist |
| Conditionals | Awkward (must return promises from if) |
Natural if/else blocks |
| Loops | Complex (Promise.all() with mapping) |
Natural for loops with await
|
| Error handling |
.catch() at end of chain |
try/catch around any block |
| Debugging | Breakpoints jump between .then() blocks |
Step through line by line |
| Return value | Always a promise | Always a promise (from async function) |
| Learning curve | New pattern to learn | Familiar control flow |
Conditional Logic Comparison
Promises with conditionals (awkward):
function processPayment(userId, amount) {
return fetchUser(userId)
.then(user => {
if (user.balance < amount) {
return Promise.reject(new Error("Insufficient funds"));
}
return deductBalance(user.id, amount);
})
.then(result => {
if (result.status === "pending") {
return notifyAdmin(result);
}
return sendReceipt(user.email, result);
});
}
Async/await with conditionals (natural):
async function processPayment(userId, amount) {
const user = await fetchUser(userId);
if (user.balance < amount) {
throw new Error("Insufficient funds");
}
const result = await deductBalance(user.id, amount);
if (result.status === "pending") {
return await notifyAdmin(result);
}
return await sendReceipt(user.email, result);
}
The async/await version reads like a script: fetch user, check balance, deduct, check status, notify or receipt. The promise version requires mentally tracking the chain and understanding where each .then() resolves.
Loop Comparison
Promises with loops (requires mapping):
function fetchAllUsers(userIds) {
return Promise.all(
userIds.map(id => fetchUser(id))
);
}
Async/await with loops (sequential or parallel):
// Sequential loop (one at a time)
async function fetchAllUsersSequential(userIds) {
const users = [];
for (const id of userIds) {
const user = await fetchUser(id);
users.push(user);
}
return users;
}
// Parallel loop (all at once)
async function fetchAllUsersParallel(userIds) {
const promises = userIds.map(id => fetchUser(id));
return await Promise.all(promises);
}
Simple Async Examples
Example 1: Fetching Data from an API
async function getWeather(city) {
try {
const response = await fetch(`https://api.weather.com/v1/current?city=${city}`);
if (!response.ok) {
throw new Error(`Weather API error: ${response.status}`);
}
const data = await response.json();
return {
temperature: data.temp,
condition: data.condition,
humidity: data.humidity
};
} catch (error) {
console.error("Failed to fetch weather:", error.message);
return null;
}
}
// Usage
const weather = await getWeather("London");
if (weather) {
console.log(`It is ${weather.temperature}°C and ${weather.condition}`);
}
Example 2: Reading a File
const fs = require('fs').promises;
async function readConfig() {
try {
const data = await fs.readFile('config.json', 'utf8');
const config = JSON.parse(data);
return config;
} catch (error) {
if (error.code === 'ENOENT') {
console.error("Config file not found, using defaults");
return { port: 3000, env: 'development' };
}
throw error;
}
}
// Usage
const config = await readConfig();
console.log("Server will run on port:", config.port);
Example 3: Delayed Execution (Sleep)
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function countdown() {
console.log("Starting countdown...");
for (let i = 3; i > 0; i--) {
console.log(i);
await delay(1000); // Wait 1 second between counts
}
console.log("Go!");
}
countdown();
Example 4: Retry Logic
async function fetchWithRetry(url, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const response = await fetch(url);
if (response.ok) {
return await response.json();
}
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (attempt === maxAttempts) {
throw new Error(`Failed after ${maxAttempts} attempts: ${error.message}`);
}
console.log(`Attempt ${attempt} failed, retrying...`);
await delay(1000 * attempt); // Exponential backoff
}
}
}
// Usage
const data = await fetchWithRetry('/api/data');
Common Pitfalls
| Pitfall | Why It Happens | The Fix |
|---|---|---|
Forgetting async |
Calling await in a non-async function |
Add async to the function declaration |
| Sequential when parallel needed | Using multiple await statements instead of Promise.all()
|
Use Promise.all() for independent operations |
| Not awaiting in loops |
array.forEach(async () => await ...) does not wait |
Use for...of loops or Promise.all(array.map(...))
|
| Swallowing errors | Empty catch blocks |
Always log or re-throw errors |
| Top-level await | Using await outside any function |
Wrap in async IIFE: (async () => { await ... })()
|
Summary
| Concept | What It Means |
|---|---|
| Async/await | Syntactic sugar over promises — cleaner syntax for the same underlying mechanism |
async function |
A function that always returns a promise and enables await inside |
await keyword |
Pauses the current async function until a promise settles, without blocking the main thread |
try/catch |
Standard error handling that works naturally with async code |
| Syntactic sugar | A nicer way to write code that compiles to the same underlying pattern (promises) |
Async/await did not introduce new capabilities to JavaScript. It introduced a better way to express existing capabilities. Every async function is a promise factory. Every await is a .then() in disguise. The transformation is not in what JavaScript can do, but in how developers think about and write asynchronous code. The result is programs that are easier to read, easier to debug, and easier to maintain — while retaining all the non-blocking performance that makes JavaScript suitable for modern web applications.
Remember: Async/await is not magic and it is not a new paradigm. It is a translator — it takes code that looks synchronous and converts it into the same promise-based code you would have written manually. The engine does the translation. You get the readability.
Top comments (0)