Skip to main content

⏳ Async Function Fundamentals

The async keyword is the modern way to write asynchronous JavaScript that reads like ordinary top-to-bottom code. In this lesson you'll learn exactly what an async function is, why it always hands back a Promise, and how to control whether your work runs one step at a time or all at once.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Declare async functions in all four forms — declaration, expression, arrow, and class method
  • Explain why an async function always returns a Promise and what value that Promise settles with
  • Rewrite a Promise .then() chain as clearer async/await code
  • Distinguish sequential from concurrent awaits and choose the faster one on purpose
  • Avoid the classic beginner traps: forgetting await and adding async where it isn't needed

Estimated Time: 30–40 minutes  •  Difficulty: Intermediate

Hands-on: Convert a real Promise chain to async/await, then make it concurrent.

In This Lesson

Why Async Functions Exist

JavaScript runs on a single thread, so it cannot afford to sit and wait while a network request travels to a server and back. Instead it registers "call me when it's done" work and moves on. For years that meant nested callbacks, which spiralled into the infamous "pyramid of doom." Promises (ES2015) flattened the pyramid into chains, and finally async/await (ES2017) let us write those chains as if they were plain synchronous code.

graph LR A[Callbacks] --> B[Promises
ES2015] B --> C[Async / Await
ES2017] A -.->|"nested, hard to read"| A C -.->|"reads top-to-bottom"| C
💡 The key insight: async/await is not a replacement for Promises — it is a friendlier syntax layered on top of them. Everything you already know about Promises still applies underneath.

The Anatomy of an Async Function

You mark a function as asynchronous by placing the async keyword in front of it. That single word does two things: it lets you use await inside the body, and it forces the function to return a Promise. Async works with every way of defining a function:

// 1. Function declaration
async function fetchUser(userId) {
  const response = await fetch(`/api/users/${userId}`);
  return response.json(); // the resolved value becomes the Promise's value
}

// 2. Function expression
const fetchUser = async function (userId) {
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
};

// 3. Arrow function
const fetchUser = async (userId) => {
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
};

// 4. Class or object method
class UserService {
  async getUser(userId) {
    const response = await fetch(`/api/users/${userId}`);
    return response.json();
  }
}

📖 Key Terms

async: a keyword before a function that makes it return a Promise and unlocks await inside it.

await: pauses the async function until the Promise beside it settles, then yields that Promise's resolved value.

settle: a Promise "settles" when it either fulfils (success) or rejects (error) — it stops being pending.

The three defining characteristics of async functions:

  • They always return a Promise. Even a plain return 42 comes back wrapped in a resolved Promise.
  • They can use await. The await keyword is a syntax error anywhere except inside an async function (or at the top level of an ES module).
  • They stay chainable. Because they return Promises, you can still call .then()/.catch() on the result if you want to.

It Always Returns a Promise

This is the single most important fact about async functions, and the one beginners forget. Whatever you return from an async function is automatically wrapped in a Promise. Whatever you throw becomes a rejected Promise.

An async function wraps its return value in a Promise A return value becomes a fulfilled Promise; a thrown error becomes a rejected Promise. Both can be consumed with await or with then and catch. async function return 42 / throw err Fulfilled Promise value = 42 Rejected Promise reason = err await or .then/.catch
Figure 1 — Return a value and you get a fulfilled Promise; throw and you get a rejected one. The caller consumes either result with await or with .then()/.catch().
async function getAnswer() {
  return 42;              // NOT a number to the caller...
}

const result = getAnswer();
console.log(result);       // Promise { 42 }  — it's a Promise!

// You unwrap it with await...
const value = await getAnswer();
console.log(value);        // 42

// ...or with .then()
getAnswer().then((v) => console.log(v)); // 42

⚠️ A rejected Promise, not a synchronous throw

Because a thrown error inside an async function becomes a rejected Promise, a plain try/catch around the call won't catch it unless you await the call. Either await it inside a try block, or attach a .catch().

Async/Await vs. Promise Chains

The clearest way to appreciate async/await is to see the same logic written both ways. Here we fetch a user, then fetch that user's posts — a two-step dependency.

The Promise-chain version

function fetchUserWithPosts(userId) {
  let user; // has to live in the outer scope to be reused later
  return fetch(`/api/users/${userId}`)
    .then((response) => {
      if (!response.ok) throw new Error('User not found');
      return response.json();
    })
    .then((userData) => {
      user = userData;
      return fetch(`/api/posts?userId=${user.id}`);
    })
    .then((response) => response.json())
    .then((posts) => ({ user, posts }))
    .catch((error) => {
      console.error('Failed to fetch user data:', error);
      throw error;
    });
}

The async/await version

async function fetchUserWithPosts(userId) {
  try {
    const userResponse = await fetch(`/api/users/${userId}`);
    if (!userResponse.ok) throw new Error('User not found');

    const user = await userResponse.json();
    const postsResponse = await fetch(`/api/posts?userId=${user.id}`);
    const posts = await postsResponse.json();

    return { user, posts };
  } catch (error) {
    console.error('Failed to fetch user data:', error);
    throw error;
  }
}

✅ What async/await buys you

  • Readability: the code flows straight down the page instead of hopping between .then() callbacks.
  • Natural error handling: one familiar try/catch replaces the .catch() chain.
  • Simple scoping: user is just a local const; no need to hoist it into an outer variable to reuse it two steps later.
  • Better debugging: breakpoints and stack traces line up with the code you actually wrote.

Sequential vs. Concurrent Work

A common myth is that async/await forces everything to happen one step at a time. It doesn't — you decide. The rule is simple: await pauses at the point you write it. If you await each task on its own line, they run back to back. If you start the tasks first and await them afterwards, they overlap.

Sequential versus concurrent async work Three sequential tasks finish one after another and take a long total time. The same three tasks run concurrently overlap and finish in roughly the time of the longest one. Sequential — total = sum of all three fetch A fetch B fetch C Concurrent — total ≈ the longest one fetch A fetch B fetch C time
Figure 2 — Three independent fetches run sequentially take the sum of their durations; started together they overlap and finish in roughly the time of the slowest one.
// Sequential — B doesn't start until A is done. Slow, and often needless.
async function loadSequential() {
  const a = await fetchA(); // wait...
  const b = await fetchB(); // ...then wait...
  const c = await fetchC(); // ...then wait
  return { a, b, c };
}

// Concurrent — start all three, THEN await. Much faster when they're independent.
async function loadConcurrent() {
  const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
  return { a, b, c };
}

💡 The deciding question

Does task B need the result of task A? If yes, they must be sequential. If no, run them concurrently with Promise.all() and shrink your total wait time dramatically.

Common Mistakes to Avoid

Mistake 1 — Forgetting to await

If you drop the await, the variable holds a pending Promise instead of the resolved value, and the following lines run before the work is finished. Worse, a rejection escapes your try/catch.

// ❌ Missing await — result is a Promise, and errors slip past the catch
async function updateUser(id, data) {
  try {
    const result = saveToDatabase(id, data); // no await!
    console.log('Saved', result);            // logs a pending Promise
  } catch (error) {
    console.error(error);                    // never runs on a save failure
  }
}

// ✅ With await — waits for the save and the catch works
async function updateUser(id, data) {
  try {
    const result = await saveToDatabase(id, data);
    console.log('Saved', result);
  } catch (error) {
    console.error(error);
  }
}

Mistake 2 — Marking functions async that don't need it

Adding async to a purely synchronous helper wraps its return value in a Promise for no reason and forces every caller to await it. Only reach for async when the body genuinely awaits something.

// ❌ Pointless async — nothing is awaited
const double = async (x) => x * 2;
const value = await double(21); // now every caller must await

// ✅ Plain function — no Promise overhead
const double = (x) => x * 2;
const value = double(21);

Mistake 3 — await inside forEach

Array's forEach ignores the Promise your async callback returns, so it does not wait. Use a for...of loop for sequential work, or Promise.all(items.map(...)) for concurrent work.

// ❌ forEach does not await — "done" logs before any item finishes
items.forEach(async (item) => { await processItem(item); });
console.log('done');

// ✅ Sequential
for (const item of items) {
  await processItem(item);
}
console.log('done');

// ✅ Concurrent
await Promise.all(items.map((item) => processItem(item)));
console.log('done');

Hands-on Exercise

🏋️ Refactor and Speed Up

Objective: Convert a Promise chain to async/await, then make an independent pair of requests run concurrently.

Starting point:

function loadProfilePage(userId) {
  return fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then((user) => {
      return fetch(`/api/settings/${userId}`)
        .then((res) => res.json())
        .then((settings) => ({ user, settings }));
    });
}

Instructions:

  1. Rewrite loadProfilePage as an async function using await.
  2. Notice that the user request and the settings request don't depend on each other — make them run concurrently.
  3. Wrap the body in a try/catch that logs a helpful message and re-throws.
💡 Hint

Both fetches only need userId, which you already have. Start both promises before awaiting, or hand both to Promise.all() and destructure the two responses.

✅ Solution
async function loadProfilePage(userId) {
  try {
    // Both requests only need userId, so run them concurrently.
    const [userRes, settingsRes] = await Promise.all([
      fetch(`/api/users/${userId}`),
      fetch(`/api/settings/${userId}`),
    ]);

    const [user, settings] = await Promise.all([
      userRes.json(),
      settingsRes.json(),
    ]);

    return { user, settings };
  } catch (error) {
    console.error(`Failed to load profile for ${userId}:`, error);
    throw error;
  }
}

The two round trips now overlap, so the page loads in roughly the time of the slower request instead of the sum of both.

🎯 Quick Quiz

Question 1: What does async function f() { return 5; } actually return when you call f()?

Question 2: Two fetches don't depend on each other. Which approach is fastest?

Question 3: Why is const double = async (x) => x * 2; considered a smell?

Summary & Quiz

🎉 Key Takeaways

  • The async keyword makes a function return a Promise and unlocks await inside it.
  • Whatever you return becomes a fulfilled Promise; whatever you throw becomes a rejected one.
  • Async/await is syntax over Promises — clearer flow, ordinary try/catch, simpler scoping.
  • You control timing: await in sequence for dependent steps, Promise.all() for independent ones.
  • Watch for the trio of traps: forgetting await, needless async, and await in forEach.

📚 Further Reading

🚀 What's Next?

You can declare async functions and return Promises from them. Next we go deep on the await keyword itself — how it interacts with the event loop and, crucially, how to handle errors cleanly when awaited Promises reject.

🎉 Well done!

Async functions are the backbone of every modern data-fetching feature you'll build from here on.