Promise chains are flatter than nested callbacks, but they still split your logic into many small handlers. async and await let you write the same promise-based code as if it were ordinary, step-by-step code, with normal variables, if statements, loops and try...catch. Under the hood, everything is still promises.
async functions
Put async in front of a function and two things change:
- The function always returns a promise. A returned value becomes the fulfilled value; a thrown error becomes the rejection.
- You can use
awaitinside it.
async function getAnswer() {
return 42;
}
console.log(getAnswer()); // → Promise { 42 } (in Node.js; browsers show Promise {<fulfilled>: 42})
getAnswer().then((n) => console.log(n)); // → 42
Every function form can be async:
async function load() {}
const loadAll = async () => {};
const api = {
async save() {},
};
await
await takes a promise and pauses the async function until that promise settles. If it fulfils, await gives you the value. If it rejects, await throws the reason as an error.
Here are the helpers from the previous lesson again, so the examples run on their own:
const courses = [
{ id: 1, title: "JavaScript Basics", authorId: 7 },
{ id: 2, title: "CSS Grid in Practice", authorId: 9 },
];
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function loadCourse(id) {
await delay(500);
const course = courses.find((c) => c.id === id);
if (!course) throw new Error(`No course with id ${id}`);
return course;
}
Notice that loadCourse no longer needs new Promise. Being async already makes it return a promise, and throw already makes that promise reject.
Compare a chain with its await version:
// Promise chain
function showCourseWithThen(id) {
return loadCourse(id)
.then((course) => loadAuthor(course.authorId))
.then((author) => render(author)); // course is out of scope here
}
// async/await
async function showCourse(id) {
const course = await loadCourse(id);
const author = await loadAuthor(course.authorId);
render(course, author);
}
In the await version, course is still in scope when render needs it. In the chain, it was trapped inside the first handler. That alone removes most reasons for nesting.
await pauses the function, not the page
await does not block the thread. When an async function reaches await, it gives control back to whoever called it. Other code, clicks and timers carry on. The function resumes later as a microtask, once the promise settles.
async function task() {
console.log("2: task starts");
await null;
console.log("4: task resumes");
}
console.log("1: before");
task();
console.log("3: after");
// → 1: before
// → 2: task starts
// → 3: after
// → 4: task resumes
The body runs synchronously up to the first await. Even await null, which waits for a value that is not a promise at all, hands control back and resumes as a microtask, after the current synchronous code has finished.
Errors: try, catch and finally
Because a rejected await throws, ordinary try...catch works again:
async function showCourse(id) {
try {
const course = await loadCourse(id);
console.log(course.title);
} catch (error) {
console.error("Could not load course:", error.message);
} finally {
console.log("Done");
}
}
await showCourse(99);
// → Could not load course: No course with id 99
// → Done
The await showCourse(99) line uses top-level await: await outside any function. It works in the browser console, in ES modules (lesson 34) and in .mjs files in Node.js. In a classic <script> or a CommonJS file it is a syntax error, so wrap the call in an async function there.
If you do not catch inside the function, the error rejects the promise the function returns, and the caller must handle it:
showCourse(1).catch((error) => console.error(error));
Catch errors where you can do something useful about them, such as showing a message. Code that cannot recover should let the error travel up to a caller that can.
Sequential versus parallel
Each await waits for the previous one to finish. That is right when a step needs the previous result, but wasteful when the steps are independent.
// Sequential: about 1000ms
console.time("sequential");
const first = await loadCourse(1);
const second = await loadCourse(2);
console.timeEnd("sequential"); // → sequential: 1004ms (roughly)
The second request does not start until the first finishes. To run independent tasks at the same time, start them all first and then wait for all of them with Promise.all():
// Parallel: about 500ms
console.time("parallel");
const [a, b] = await Promise.all([loadCourse(1), loadCourse(2)]);
console.timeEnd("parallel"); // → parallel: 502ms (roughly)
console.log(a.title, "and", b.title);
// → JavaScript Basics and CSS Grid in Practice
Promise.all() takes an array of promises and returns one promise that fulfils with an array of their results, in the same order. If any of them rejects, the combined promise rejects with that error straight away. Lesson 43 covers Promise.all() and its siblings, including how to keep the successful results when some tasks fail.
The key idea: a promise starts its work when it is created, not when it is awaited. Calling loadCourse(1) and loadCourse(2) without await starts both timers at once.
await in loops
for…of runs one at a time
for (const id of [1, 2]) {
const course = await loadCourse(id);
console.log(course.title);
}
This is sequential, which is what you want when order matters or when a server limits how many requests you may send at once.
map plus Promise.all runs in parallel
const titles = await Promise.all(
[1, 2].map(async (id) => {
const course = await loadCourse(id);
return course.title;
})
);
console.log(titles); // → [ 'JavaScript Basics', 'CSS Grid in Practice' ]
forEach does not wait
// Bug: "All loaded" logs first
[1, 2].forEach(async (id) => {
const course = await loadCourse(id);
console.log(course.title);
});
console.log("All loaded");
forEach calls the async callback, ignores the promise it returns and moves on. Nothing waits, and nothing catches errors. Use for...of or map with Promise.all() instead.
Common mistakes
Forgetting await
async function printTitle() {
const course = loadCourse(1); // missing await
console.log(course.title); // → undefined
}
course is a promise, which has no title property. If a value is unexpectedly undefined or logs as a Promise, look for a missing await.
Returning without await inside try
async function safeLoad(id) {
try {
return await loadCourse(id); // await needed for catch to see the error
} catch {
return null;
}
}
Without await, the function returns the pending promise straight away and the try block has already ended when it rejects, so catch never runs. Inside try, write return await.
Mixing styles
You can call .then() on the result of an async function, and await any promise, so the two styles mix freely. Pick one style per function: await for most code, .then() for a quick one-liner.
Summary
- An
asyncfunction always returns a promise.returnfulfils it andthrowrejects it. awaitpauses only the async function until a promise settles, then gives you the value or throws the error.- Use
try,catchandfinallyaround awaited calls, or let the caller handle the rejection. - Top-level
awaitworks in modules and the console, not in classic scripts. - Independent work should run in parallel: start the promises, then
await Promise.all([...]). for...ofwithawaitis sequential;mapwithPromise.all()is parallel;forEachwith an async callback never waits.- Inside
try, usereturn awaitsocatchcan see the rejection.