By Rahul — Google Frontend Engineer
What Problem Does It Solve?
Before async/await, we had callback hell and promise chains. async/await lets you write asynchronous code that LOOKS synchronous.
// Promise chain
fetchUser(id)
.then(user => fetchPosts(user.id))
.then(posts => fetchComments(posts[0].id))
.then(comments => console.log(comments))
.catch(err => console.error(err));
// async/await — same thing, readable
async function getComments(id) {
try {
const user = await fetchUser(id);
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);
console.log(comments);
} catch (err) {
console.error(err);
}
}How It Works Under the Hood
An async function ALWAYS returns a Promise. The await keyword pauses execution of the async function until the Promise resolves. Under the hood, it uses microtasks — the code after await is scheduled as a microtask.
async function foo() {
console.log('A'); // Sync
await somePromise; // Pauses here
console.log('B'); // Runs as microtask after promise resolves
}
// Is equivalent to:
function foo() {
console.log('A');
return somePromise.then(() => {
console.log('B');
});
}Common Mistakes
Sequential When You Want Parallel
// BAD — sequential: takes 2 seconds
async function slow() {
const a = await fetch('/api/a'); // 1 second
const b = await fetch('/api/b'); // 1 second (waits for a)
}
// GOOD — parallel: takes 1 second
async function fast() {
const [a, b] = await Promise.all([
fetch('/api/a'),
fetch('/api/b')
]);
}Forgetting Error Handling
// BAD — unhandled rejection
async function risky() {
const data = await fetch('/api/data'); // If this fails?
return data.json();
}
// GOOD — always handle errors
async function safe() {
try {
const data = await fetch('/api/data');
if (!data.ok) throw new Error(`HTTP ${data.status}`);
return data.json();
} catch (err) {
console.error('Fetch failed:', err);
return null; // Graceful fallback
}
}await in Loops
// BAD — sequential
for (const url of urls) {
await fetch(url); // One at a time
}
// GOOD — parallel with limit
async function fetchWithLimit(urls, limit = 5) {
const results = [];
for (let i = 0; i < urls.length; i += limit) {
const batch = urls.slice(i, i + limit);
const batchResults = await Promise.all(batch.map(fetch));
results.push(...batchResults);
}
return results;
}Top-Level Await
// Works in ES modules (not CommonJS)
const config = await fetch('/config.json').then(r => r.json());
export default config;
// The module waits until config is loaded before exportingProduction Best Practices
- Always wrap await in try/catch
- Use Promise.all for independent async operations
- Add timeouts to avoid hanging forever:
Promise.race([fetch(url), timeout(5000)]) - Use AbortController to cancel fetch requests when components unmount
Summary
async/await is syntactic sugar over Promises. It makes async code readable but introduces subtle bugs around parallelism and error handling. Always use Promise.all for independent operations and try/catch for error handling.