JavaScript async/await: Readable Async Code, Error Handling and Waiting in Parallel
We see why async functions always return promises, how await waits without freezing the page, catching errors with try/catch, sequential versus parallel waiting, the await-in-a-loop trap and top-level await.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
An async function, an await inside it and two console.log calls outside. In what order are the numbers printed?
async function run() {
console.log("2");
await null;
console.log("4");
}
console.log("1");
run();
console.log("3");
Make a guess before you keep reading. await null looks like it waits for next to nothing; but does it really wait for nothing? We'll come back to the answer once we've explained what await does.
An async Function Always Returns a Promise
In the previous post we flattened the callback pyramid with promise chains. async/await, which arrived with ES2017, lets us write those same promises like ordinary code that reads from top to bottom. It isn't a new asynchronous model; it's syntax built on top of promises.
The first rule is this: when you put async in front of a function, that function always returns a promise. Try it in the console:
async function getNumber() {
return 42;
}
const result = getNumber();
console.log(result instanceof Promise); // true
result.then((value) => console.log(value)); // 42
We wrote return 42, but what we got back isn't 42; it's a promise that fulfills with 42. If you throw inside the function, what comes out isn't an error flying outward but a rejected promise:
async function fail() {
throw new Error("Something went wrong");
}
fail().catch((error) => console.error(error.message));
async works with every kind of function: async function load() {}, const load = async () => {} or async load() {} inside a class.
await: Wait, but Don't Freeze the Page
await waits for a promise to settle and gives you its value. It can only be used inside an async function or, as we'll see shortly, at the top level of a module. Remember the wait function from the previous post:
function wait(ms) {
return new Promise((resolve) => setTimeout(() => resolve(`${ms} ms passed`), ms));
}
async function demo() {
console.log("Started");
const message = await wait(1000);
console.log(message); // "1000 ms passed"
console.log("Done");
}
demo();
Don't let the word "wait" scare you. await doesn't freeze the main thread; it only pauses the function it's in. Picture ordering a coffee at a café. The cashier hands you a receipt, you step aside, and in the meantime the cashier serves other customers. When your coffee is ready they call your name and you pick up where you left off. await likewise steps the function aside, the call stack empties and the event loop gets on with other work. When the promise settles, the function resumes from the line where it stopped.
Now back to the opening question. First 1 is printed. When run() is called, the function runs synchronously up to its first await; 2 is printed. At await null the function pauses and control returns to the caller, so 3 is printed. Once the script is done, the function resumes and 4 is printed. The answer is 1, 2, 3, 4. Even if you await a value that isn't a promise, await turns it into an already-resolved promise and the function still pauses; it doesn't continue until the currently running code is finished.
From a Promise Chain to async/await
Remember the user, orders and order-detail chain from the previous post. In that post's last exercise we struggled to reach the user's name at the final step. With async/await that problem simply disappears, because every result sits in an ordinary variable:
function getUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id <= 0) reject(new Error("Invalid user id"));
else resolve({ id, name: "Ellie" });
}, 500);
});
}
function getOrders(userId) {
return wait(500).then(() => [{ id: 101 }, { id: 102 }]);
}
function getOrderDetail(orderId) {
return wait(500).then(() => ({ id: orderId, total: 349.9 }));
}
async function showFirstOrder(userId) {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const detail = await getOrderDetail(orders[0].id);
console.log(`${user.name} paid ${detail.total} on their first order`);
}
showFirstOrder(1);
No then, no nested arrow functions. The code reads top to bottom like synchronous code, yet no line freezes the page. Underneath, the very same promises are running; they're just much easier to read.
Catching Errors: try/catch Is Back
The try/catch/finally we saw earlier catches asynchronous errors too when combined with await. When you await a rejected promise, that line behaves as if it contained a throw:
async function showUser(id) {
console.log("Loading...");
try {
const user = await getUser(id);
console.log(`Welcome, ${user.name}`);
} catch (error) {
console.error(`Error: ${error.message}`);
} finally {
console.log("Loading finished");
}
}
showUser(1); // Loading... → Welcome, Ellie → Loading finished
showUser(-1); // Loading... → Error: Invalid user id → Loading finished
Now there's a single tool for both synchronous and asynchronous errors. Here too, finally is the right place to hide a loading indicator, because it runs whether the work ends in success or failure.
Keep in mind that an async function returns a promise. If you didn't catch the error inside, you can catch it outside with showUser(1).catch(...). If neither is there, you'll meet the "Uncaught (in promise)" error from the previous post.
Sequential or Parallel?
The readability of await brings a trap with it: awaiting everything one line after another. Run the two functions below in the console and compare their timings:
async function sequential() {
console.time("sequential");
const a = await wait(1000);
const b = await wait(1000);
const c = await wait(1000);
console.timeEnd("sequential"); // about 3000 ms
}
async function parallel() {
console.time("parallel");
const [a, b, c] = await Promise.all([wait(1000), wait(1000), wait(1000)]);
console.timeEnd("parallel"); // about 1000 ms
}
sequential().then(parallel);
In the first one, each wait waits for the previous one to finish and the times add up. In the second, all three tasks start at once and Promise.all waits only as long as the longest. We split the results in a single line with the array destructuring we saw earlier.
Which one you choose depends on whether the tasks depend on each other. In showFirstOrder we needed the user's id to request the orders; waiting in sequence is unavoidable there. But if a page loads three unrelated pieces of data, say the user's profile, notifications and the weather, awaiting them one by one makes the user wait for no reason.
The await-in-a-Loop Trap
When you want to load data for an array of ids, forEach is the first thing that comes to mind. But forEach doesn't care whether the function you give it returns a promise:
async function loadAll() {
const ids = [1, 2, 3];
ids.forEach(async (id) => {
const user = await getUser(id);
console.log(user.id);
});
console.log("All loaded"); // this prints first!
}
loadAll();
"All loaded" comes out first, because forEach starts three async functions and moves on without waiting. If you really need to wait, there are two correct ways. For sequential work, use for...of; there, await genuinely pauses the loop. For parallel work, build an array of promises with the map we saw earlier and wait for them with Promise.all:
async function loadSequential(ids) {
for (const id of ids) {
const user = await getUser(id);
console.log("sequential:", user.id);
}
console.log("Sequential load finished");
}
async function loadParallel(ids) {
const users = await Promise.all(ids.map((id) => getUser(id)));
console.log("parallel:", users.map((user) => user.id));
}
loadSequential([1, 2, 3]).then(() => loadParallel([1, 2, 3]));
The sequential version takes about a second and a half, the parallel one half a second. Waiting in sequence inside a loop is sometimes exactly what you want, for instance when you don't want to hit a server with lots of requests at once. What matters is choosing it on purpose.
Top-Level await: At the Top of a Module
Since ES2022 we can also use await outside a function, at the top level of a module. For that, the script has to be a module. Save the file below and open it in your browser:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Top-level await</title>
</head>
<body>
<p id="out">Waiting...</p>
<script type="module">
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
await wait(1500);
document.querySelector("#out").textContent = "Ready after 1.5 seconds";
</script>
</body>
</html>
Remove type="module" and the browser reports a SyntaxError on the await line; classic scripts don't have top-level await. The browser's DevTools console, on the other hand, lets you write await at the top level as a convenience, which is why await wait(1000) works there. We'll cover the rest of modules, along with import and export, in detail in a later post.
Common Pitfalls
The first is forgetting await. You get no error, but you're holding a promise instead of a value:
async function broken() {
const user = getUser(1); // no await
console.log(user.name); // undefined, because user is a Promise
}
broken();
The second is using await inside a function that isn't async. That's a SyntaxError, and you'll most often run into it in an ordinary callback inside an async function. The outer function being async doesn't cover the inner one; the function that contains the await must itself be async.
The third is needlessly awaiting independent tasks in sequence. The code works correctly, it's just slow, which is exactly why it's hard to notice. When you see several await lines stacked up, ask yourself: "do these actually need each other's results?"
The fourth is calling an async function without adding either await or catch. If something fails inside, nobody catches it. If you call loadData() from an event handler, catch the error either inside the function with try/catch or outside with .catch().
A Small Experiment
★☆☆ Rewrite the "One, Two, Three" exercise from the previous post with async/await and wait. Which version is easier to read?
★★☆ Predict the output and its order, then try it:
async function a() {
console.log("a1");
await wait(0);
console.log("a2");
}
async function b() {
console.log("b1");
await null;
console.log("b2");
}
a();
b();
console.log("end");
★★★ Using getUser, write a function that sends parallel requests for the ids [1, 2, -1, 3]. Don't let Promise.all reject entirely because of one error; produce an array of results that holds either the user's name or "error" for each id. Hint: you can wrap each promise with its own catch before handing it to Promise.all.
Everything we've awaited so far has been fake work simulated with setTimeout. In the next post we'll talk to a real server: we'll see how to fetch and send data with the Fetch API, deal with HTTP status codes and timeouts, and show loading and error states on the page.