Difficulty: Intermediate
Async/await, introduced in ES2017, is syntactic sugar over promises that makes asynchronous code look and behave more like synchronous code. An async function always returns a promise. If the function returns a value, that value is automatically wrapped in Promise.resolve(). If the function throws, the thrown value is wrapped in Promise.reject(). This means async functions are fully interoperable with promise-based code - you can use .then() on the result of an async function, and you can await any promise.
The await keyword can only be used inside an async function (or at the top level of a module). When JavaScript encounters await, it pauses the execution of the async function until the awaited promise settles. If the promise fulfills, await returns the resolved value. If the promise rejects, await throws the rejection reason, which can be caught with try/catch. Critically, while the async function is paused, the rest of your program continues to run - await does not block the main thread. It simply suspends that specific function's execution.
Error handling with async/await uses the familiar try/catch/finally syntax from synchronous code. You wrap the await expression in a try block and catch rejections in the catch block. This is more natural than .then().catch() chains because you can handle errors from multiple sequential await calls in a single try/catch block. The finally block runs regardless of success or failure, just like .finally() on promises.
A common performance mistake with async/await is making independent operations sequential when they could be parallel. If you write const a = await fetchA(); const b = await fetchB();, fetchB does not start until fetchA completes. If these operations are independent, you should use Promise.all([fetchA(), fetchB()]) to run them concurrently. The difference can be dramatic: two 1-second operations take 2 seconds sequentially but only 1 second in parallel.
Sequential execution is appropriate when operations depend on each other - when the second operation needs the result of the first. Parallel execution is appropriate when operations are independent. Understanding when to use each pattern is crucial for writing performant async code. You can also combine patterns: fetch some data sequentially, then use those results to fire off parallel operations.
Top-level await, available in ES modules (not CommonJS), allows you to use await outside of an async function at the module level. This is useful for module initialization that depends on asynchronous operations, such as loading configuration, establishing database connections, or importing dynamic modules. When a module uses top-level await, any module that imports it will wait for the await to complete before executing.
function fetchUser(id) {
return new Promise(resolve => {
setTimeout(() => resolve({ id, name: 'Alice', posts: [1, 2] }), 100);
});
}
function fetchPost(postId) {
return new Promise(resolve => {
setTimeout(() => resolve({ id: postId, title: `Post ${postId}` }), 100);
});
}
// Promise chain approach
fetchUser(1)
.then(user => fetchPost(user.posts[0]))
.then(post => console.log('Promise:', post.title));
// Async/await approach - same logic, reads like sync code
async function getUserFirstPost(userId) {
const user = await fetchUser(userId);
const post = await fetchPost(user.posts[0]);
console.log('Async:', post.title);
return post;
}
getUserFirstPost(1);
// Async functions always return a promise
const result = getUserFirstPost(1);
console.log('Is promise:', result instanceof Promise);
Both approaches do the same thing, but async/await reads top-to-bottom like synchronous code. The async function pauses at each await, resumes when the promise settles, and stores the resolved value in a variable. The function always returns a promise.
function fetchData(shouldFail) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) reject(new Error('Network error'));
else resolve({ status: 200, data: 'Success' });
}, 50);
});
}
async function loadDashboard() {
try {
console.log('Loading user data...');
const userData = await fetchData(false);
console.log('User:', userData.data);
console.log('Loading analytics...');
const analytics = await fetchData(true); // this will fail
console.log('Analytics:', analytics.data); // never reached
} catch (error) {
console.log('Error caught:', error.message);
} finally {
console.log('Loading complete (cleanup)');
}
}
loadDashboard();
// Per-operation error handling
async function resilientLoad() {
let userData = null;
let analytics = null;
try {
userData = await fetchData(false);
} catch (e) {
console.log('User load failed, using default');
userData = { data: 'default user' };
}
try {
analytics = await fetchData(true);
} catch (e) {
console.log('Analytics failed, using fallback');
analytics = { data: 'no analytics' };
}
console.log('Result:', userData.data, '|', analytics.data);
}
resilientLoad();
The first example uses a single try/catch - one failure stops everything. The second example wraps each operation in its own try/catch, allowing independent error recovery. Choose the pattern based on whether operations should fail together or independently.
function delay(ms, label) {
return new Promise(resolve => {
setTimeout(() => {
console.log(` ${label} finished`);
resolve(label);
}, ms);
});
}
// SEQUENTIAL - total time: 300ms
async function sequential() {
const start = Date.now();
const a = await delay(100, 'A');
const b = await delay(100, 'B');
const c = await delay(100, 'C');
const elapsed = Date.now() - start;
console.log(`Sequential: [${a}, ${b}, ${c}] in ~${elapsed}ms`);
}
// PARALLEL - total time: 100ms
async function parallel() {
const start = Date.now();
const [a, b, c] = await Promise.all([
delay(100, 'X'),
delay(100, 'Y'),
delay(100, 'Z')
]);
const elapsed = Date.now() - start;
console.log(`Parallel: [${a}, ${b}, ${c}] in ~${elapsed}ms`);
}
sequential();
setTimeout(() => parallel(), 400);
Sequential awaits each delay one after another, taking 300ms total. Parallel starts all three delays simultaneously using Promise.all, taking only 100ms - a 3x improvement. Use parallel when operations are independent.
function unreliableFetch(url, failCount) {
let attempts = 0;
return function() {
return new Promise((resolve, reject) => {
attempts++;
setTimeout(() => {
if (attempts <= failCount) {
reject(new Error(`Attempt ${attempts} failed for ${url}`));
} else {
resolve({ url, data: 'response data', attempt: attempts });
}
}, 50);
});
};
}
async function fetchWithRetry(fetchFn, maxRetries = 3, delayMs = 100) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await fetchFn();
console.log(`Success on attempt ${attempt}`);
return result;
} catch (error) {
console.log(`Attempt ${attempt} failed: ${error.message}`);
if (attempt === maxRetries) {
throw new Error(`All ${maxRetries} attempts failed`);
}
// Wait before retrying
await new Promise(r => setTimeout(r, delayMs));
}
}
}
// Succeeds on 3rd attempt
const fetchApi = unreliableFetch('/api/data', 2);
async function main() {
try {
const result = await fetchWithRetry(fetchApi, 3, 50);
console.log('Final result:', result.url, '- attempt', result.attempt);
} catch (err) {
console.log('Gave up:', err.message);
}
}
main();
This retry pattern uses a for loop with try/catch inside an async function. Each failed attempt catches the error, logs it, waits, and tries again. On the final attempt, the error is re-thrown. This pattern is common in production code for dealing with flaky network requests.
async function, await, try/catch, Promise.all, sequential vs parallel, top-level await