⏳ 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
awaitand addingasyncwhere 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.
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 42comes back wrapped in a resolved Promise. - They can use
await. Theawaitkeyword 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.
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/catchreplaces the.catch()chain. - Simple scoping:
useris just a localconst; 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 — 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:
- Rewrite
loadProfilePageas anasyncfunction usingawait. - Notice that the user request and the settings request don't depend on each other — make them run concurrently.
- Wrap the body in a
try/catchthat 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
asynckeyword makes a function return a Promise and unlocksawaitinside it. - Whatever you
returnbecomes a fulfilled Promise; whatever youthrowbecomes a rejected one. - Async/await is syntax over Promises — clearer flow, ordinary
try/catch, simpler scoping. - You control timing:
awaitin sequence for dependent steps,Promise.all()for independent ones. - Watch for the trio of traps: forgetting
await, needlessasync, andawaitinforEach.
📚 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.