Skip to main content

⏳ 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 async function and explain why it always returns a Promise
  • Use await to pause for a Promise and read its resolved value
  • Handle async errors with try/catch/finally
  • Distinguish sequential (await one by one) from parallel (Promise.all) execution
  • Rewrite a Promise chain as clean async/await code
  • Avoid the classic traps: missing await, await in forEach, 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 chainAsync/await
function load() {
  return fetch('/api/user')
    .then(r => r.json())
    .then(user => {
      console.log(user);
      return user;
    });
}
async function load() {
  const r = await fetch('/api/user');
  const user = await r.json();
  console.log(user);
  return user;
}

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}!`);
}
An async function wraps its return value in a resolved Promise and its thrown errors in a rejected Promise async function runs your code return value β†’ fulfilled Promise throw error β†’ rejected Promise
Whatever an async function returns becomes a fulfilled Promise; whatever it throws becomes a rejected one.

πŸ’‘ 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:

graph TD A[Call async function] --> B[Run until first await] B --> C[Hit await: hand control
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 use finally for cleanup
  • Run independent work with Promise.all instead of serial awaits
  • Check response.ok after fetch and throw on non-2xx responses
  • Use for…of (not forEach) 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 .catch or a surrounding try/catch
  • Add return await outside a try where 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.

graph LR A[Callbacks] -->|structured by| B[Promises] B -->|sugar-coated by| C[async / await] C -.built on.-> B

Summary

πŸŽ‰ Key Takeaways

  • async/await is syntactic sugar over Promises β€” same engine, cleaner reading
  • An async function always returns a Promise; a thrown error becomes a rejection
  • await pauses 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.all to run independent work in parallel
  • Watch the classics: missing await, await in forEach, 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.