β³ Async/await
Promises fixed the structure of async code, but chains of .then still read a little sideways. async/await is the finishing touch: a thin layer of syntax over Promises that lets you write asynchronous code that flows straight down the page β pause here, get a value, use it on the next line β with the ordinary try/catch you already know for errors.
Week 2 · Day 4 (Thursday: Asynchronous JavaScript) · Lecture 3
π― Learning Objectives
By the end of this lesson, you will be able to:
- Declare an
asyncfunction and explain why it always returns a Promise - Use
awaitto pause for a Promise and read its resolved value - Handle async errors with
try/catch/finally - Distinguish sequential (
awaitone by one) from parallel (Promise.all) execution - Rewrite a Promise chain as clean async/await code
- Avoid the classic traps: missing
await,awaitinforEach, needless serialization
Estimated Time: 70 minutes
Practice: Convert a Promise chain to async/await and build a parallel dashboard loader.
In This Lesson
What Is Async/await?
Async/await, introduced in ES2017, is syntactic sugar over Promises. It doesn't add new capabilities β under the hood it's still Promises and the event loop from the last two lessons β but it changes how the code reads. It's the difference between a recipe written in dense culinary shorthand and the same recipe written in plain, numbered steps: same dish, far easier to follow.
Compare the two styles doing the identical job:
| Promise chain | Async/await |
|---|---|
|
|
The async version reads top to bottom like synchronous code β but it never blocks the thread. Each await quietly hands control back to the event loop while it waits, then resumes right where it left off.
The async Keyword
Putting async before a function does two things: it lets you use await inside, and it makes the function always return a Promise β even if your code returns a plain value.
async function greet(name) {
return `Hello, ${name}!`; // a plain string...
}
// ...but calling it gives you a Promise, not the string
console.log(greet('Alice')); // Promise { <fulfilled>: 'Hello, Alice!' }
greet('Alice').then(msg => console.log(msg)); // 'Hello, Alice!'
// So this async function is equivalent to:
function greetManual(name) {
return Promise.resolve(`Hello, ${name}!`);
}
π‘ Why "always a Promise" matters
Because the return type is uniform, callers can always await or .then the result without checking whether the work was actually async. That consistency is what makes async functions compose so cleanly.
The await Keyword
await can only appear inside an async function (or at the top level of a module). It pauses the function until the Promise on its right settles, then evaluates to the resolved value. If the Promise rejects, await throws that reason as an exception.
async function processUser() {
console.log('Starting to fetch user...');
const user = await fetchUser(1); // pause here until it resolves
console.log('User fetched:', user.name);
const posts = await fetchPosts(user.id); // then pause for this one
console.log('Posts fetched:', posts.length);
return { user, posts }; // resolves the returned Promise
}
Here's the crucial mental model: await does not block the thread. While processUser is paused, the event loop is free to run other code β timers, clicks, other async functions. When the awaited Promise settles, the engine schedules the rest of processUser as a microtask and resumes it. The flow diagram:
back to the event loop] C --> D{Awaited Promise settled?} D -->|Fulfilled| E[Resume with the value] D -->|Rejected| F[Throw β jump to catch] E --> G[Continue to next await
or return] F --> G
Errors with try/catch
This is where async/await really shines. Because a rejected await throws, you handle async errors with the exact same try/catch/finally you'd use for synchronous code β no separate .catch to remember.
async function robustDataFetching() {
try {
const user = await fetchUser(1);
if (!user.isActive) {
throw new Error('User is not active'); // your own throws work too
}
const preferences = await fetchUserPreferences(user.id);
const recommendations = await fetchRecommendations(preferences);
return { user, preferences, recommendations };
} catch (error) {
// Catches rejections from ANY await above, plus your own throws
console.error('Operation failed:', error.message);
if (error.name === 'NetworkError') {
return { error: 'Connection failed. Check your internet.' };
}
return { error: 'An unexpected error occurred.' };
} finally {
// Runs whether we succeeded or failed β perfect for cleanup
console.log('Data fetching complete');
}
}
β One catch, many awaits
A single try/catch covers every await inside it, just as one trailing .catch covered a whole Promise chain. You can also let the error propagate: an async function that throws returns a rejected Promise, so the caller can try/catch it in turn.
Sequential vs. Parallel
The single most common async/await performance mistake is awaiting independent operations one after another when they could run at the same time. Awaiting is a pause β so consecutive awaits are strictly sequential.
Sequential β each waits for the last (slower)
async function fetchSequential() {
console.time('Sequential');
const user = await fetchUser(1); // ~1s
const posts = await fetchPosts(1); // ~1s (starts only after user)
const comments = await fetchComments(1); // ~1s (starts only after posts)
console.timeEnd('Sequential'); // ~3 seconds total
return { user, posts, comments };
}
Use this style only when each step genuinely needs the previous step's result.
Parallel β start all, then await together (faster)
async function fetchParallel() {
console.time('Parallel');
// Kick off all three NOW (no await yet) β they run concurrently
const userPromise = fetchUser(1);
const postsPromise = fetchPosts(1);
const commentsPromise = fetchComments(1);
// Then wait for all of them at once
const [user, posts, comments] = await Promise.all([
userPromise, postsPromise, commentsPromise,
]);
console.timeEnd('Parallel'); // ~1 second total
return { user, posts, comments };
}
β οΈ The tell-tale sign
If two awaits don't use each other's results, they probably shouldn't be sequential. Start the Promises first, then await Promise.all([...]). In the example above that's the difference between 3 seconds and 1.
Real-World Examples
Example 1: Aggregating API data with fetch
A modern data loader using fetch (which returns Promises), with a proper response-status check that's easy to forget:
async function getWeatherReport(city) {
try {
const res = await fetch(`/api/weather/current?city=${encodeURIComponent(city)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); // fetch only rejects on network errors
const current = await res.json();
const forecastRes = await fetch(`/api/weather/forecast?city=${encodeURIComponent(city)}`);
if (!forecastRes.ok) throw new Error(`HTTP ${forecastRes.status}`);
const forecast = await forecastRes.json();
return {
current,
forecast,
summary: `${city}: ${current.temp}Β°C, ${current.conditions}`,
};
} catch (error) {
console.error('Weather API error:', error.message);
return { error: 'Unable to fetch weather data' };
}
}
π‘ fetch doesn't reject on 404
fetch only rejects on network failure. An HTTP 404 or 500 still resolves β so always check response.ok and throw yourself, as above.
Example 2: Polling with a while loop
Async/await makes loop-based async logic read naturally β something that's genuinely awkward with raw Promises:
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
async function pollForCompletion(processingId, onProgress) {
const maxAttempts = 30;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch(`/api/status/${processingId}`);
const status = await res.json();
if (status.complete) return status.result;
if (status.error) throw new Error(status.error);
onProgress(status.progress);
await delay(1000); // wait a second, then poll again
}
throw new Error('Processing timeout');
}
Example 3: Retry with exponential backoff
async function retryWithBackoff(operation, maxRetries = 3, baseDelay = 1000) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation(); // success β done
} catch (error) {
lastError = error;
if (attempt === maxRetries - 1) break;
const wait = baseDelay * 2 ** attempt; // 1s, 2s, 4s, ...
console.log(`Attempt ${attempt + 1} failed, retrying in ${wait}ms`);
await delay(wait);
}
}
throw lastError;
}
Common Pitfalls
Pitfall 1: Forgetting await
async function buggy() {
const data = fetchData(); // β no await β data is a Promise, not the value
console.log(data.name); // undefined / TypeError
const good = await fetchData(); // β
console.log(good.name);
}
Pitfall 2: await inside forEach
// β forEach ignores the returned Promises β "Done" logs immediately
items.forEach(async (item) => {
await processItem(item);
});
console.log('Done'); // runs BEFORE any item finishes
// β
use a for...of loop, which honors await
for (const item of items) {
await processItem(item);
}
console.log('Done'); // runs after all items
Pitfall 3: Serializing independent work
// β These two don't depend on each other, yet run one after the other
const a = await fetchA();
const b = await fetchB();
// β
Run them together
const [a2, b2] = await Promise.all([fetchA(), fetchB()]);
Pitfall 4: Needless return await
// Redundant β the async function already wraps the result in a Promise
async function getUser() { return await fetchUser(); }
// Simpler (identical behavior in most cases)
async function getUser2() { return fetchUser(); }
// Exception: keep `return await` inside try/catch so errors are caught here.
Practice & Quiz
ποΈ Exercise 1: Convert a Promise chain
Goal: Rewrite this nested Promise chain as a single flat async function that returns { user, orders, items } and returns null on error.
function getUserData(userId) {
return getUser(userId)
.then(user => getOrders(user.id)
.then(orders => getOrderItems(orders[0].id)
.then(items => ({ user, orders, items }))))
.catch(error => { console.error(error); return null; });
}
π‘ Hint
Each .then becomes an await on its own line. Wrap them in try/catch; the .catch becomes the catch block returning null.
β Solution
async function getUserData(userId) {
try {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const items = await getOrderItems(orders[0].id);
return { user, orders, items };
} catch (error) {
console.error('Error:', error.message);
return null;
}
}
ποΈ Exercise 2: Parallel dashboard loader
Goal: Write getUserDashboard(userId) that fetches the user, their posts, and their comments in parallel, returns all three, and handles errors.
π‘ Hint
Don't await each call separately β pass all three Promises to Promise.all and destructure the result array.
β Solution
async function getUserDashboard(userId) {
try {
const [user, posts, comments] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchComments(userId),
]);
return { user, posts, comments };
} catch (error) {
console.error('Failed to load dashboard:', error.message);
return { error: 'Could not load dashboard' };
}
}
π― Quick Quiz
Question 1: What does an async function always return?
Question 2: Two independent fetches must both finish before you continue. Fastest correct approach?
Question 3: Why doesn't await inside array.forEach(async β¦) work as expected?
Best Practices
β Do
- Wrap awaited code in
try/catch, and usefinallyfor cleanup - Run independent work with
Promise.allinstead of serial awaits - Check
response.okafterfetchand throw on non-2xx responses - Use
forβ¦of(notforEach) when you need to await inside a loop
β Don't
- Forget
awaitβ you'll operate on a Promise instead of its value - Serialize awaits that don't depend on one another
- Leave a top-level async call without
.catchor a surroundingtry/catch - Add
return awaitoutside atrywhere it's redundant
β The three approaches, one lineage
Callbacks, Promises, and async/await aren't rivals β they're layers. Async/await is built on Promises, which tame callbacks. Prefer async/await for new code; you'll still meet all three in the wild.
Summary
π Key Takeaways
async/awaitis syntactic sugar over Promises β same engine, cleaner reading- An async function always returns a Promise; a thrown error becomes a rejection
awaitpauses the function (not the thread) until the Promise settles, then yields its value- Handle errors with familiar
try/catch/finally - Serial awaits are sequential; use
Promise.allto run independent work in parallel - Watch the classics: missing
await,awaitinforEach, needless serialization
π Additional Resources
π What's Next?
You now have the full async toolkit β callbacks, Promises, and async/await. The next lesson zooms in on the error side of the story: how try/catch blocks work in depth, custom error types, throwing and re-throwing, and writing code that fails gracefully instead of silently.
π You've mastered async JavaScript!
Callbacks, the event loop, Promises, and async/await β the hardest conceptual hill in the language is behind you. Everything from fetch to Node's file system builds on exactly this.