async/await in JavaScript: A Practical Guide
A practical guide to async functions and await — writing asynchronous code that reads like synchronous code, handling errors with try/catch, and running work in parallel.
What you'll learn
- ✓How async functions and await work under the hood
- ✓Writing asynchronous code that reads top-to-bottom
- ✓Error handling with try/catch around await
- ✓When awaits run sequentially — and how to parallelize
- ✓A short fetch-and-display walkthrough
- ✓What top-level await lets you do (and where)
Prerequisites
- •JavaScript promises — see Promises
- •Arrow functions — see Arrow Functions
async/await is the syntax that made asynchronous JavaScript look ordinary. Under the hood, it’s still promises — exactly the ones from the previous post. The keywords let you write code that reads top to bottom, with familiar try/catch for errors, while the engine handles the suspension and resumption.
If promises feel like plumbing, async/await is the faucet.
async functions
Any function can be made async by adding the keyword in front:
async function greet() {
return "hello";
}
console.log(greet()); // Promise { 'hello' }
An async function always returns a promise. Whatever you return becomes the fulfillment value; if you throw, the promise rejects. The console.log above prints a promise, not the string.
To consume the result:
greet().then((value) => console.log(value)); // 'hello'
The same applies to arrow functions and methods:
const greet = async (name) => `hello ${name}`;
class Greeter {
async greet(name) {
return `hello ${name}`;
}
}
By itself, async is just a way to make a function return a promise. The real magic is await.
await
Inside an async function, await pauses execution until the promise on its right settles:
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function run() {
console.log("before");
await delay(500);
console.log("after 500ms");
await delay(500);
console.log("after another 500ms");
}
run();
await delay(500) evaluates the expression on the right (a promise), then suspends the function until that promise fulfills. The function then resumes on the next line.
If the awaited promise fulfills, await gives you its value:
async function loadUser() {
const response = await fetch("/api/user");
const user = await response.json();
return user;
}
If it rejects, await throws — which means you handle errors with normal try/catch.
Error handling with try/catch
This is the big readability win. Asynchronous errors flow through the same construct as synchronous ones:
async function loadUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const user = await response.json();
return user;
} catch (err) {
console.error("Failed to load user:", err.message);
throw err; // optional — re-raise so callers can handle it too
}
}
Compare with the equivalent promise chain — both are perfectly valid, but the try/catch form scales better as logic grows. Especially when you have conditional branches, loops, or both success and failure paths that share cleanup.
A finally block does what you’d expect — runs whether the try block finished normally or threw:
async function withTimer(work) {
const start = Date.now();
try {
return await work();
} finally {
console.log(`took ${Date.now() - start}ms`);
}
}
Sequential vs parallel awaits
This is the single biggest performance pitfall with async/await. Each await is a barrier — the function won’t proceed until the awaited promise settles. So this code runs the three requests one after the other:
async function loadAllSequential() {
const user = await fetch("/api/user");
const posts = await fetch("/api/posts");
const comments = await fetch("/api/comments");
return { user, posts, comments };
}
Total time: the sum of all three requests.
If the three requests don’t depend on each other, kick them all off first, then await:
async function loadAllParallel() {
const userPromise = fetch("/api/user");
const postsPromise = fetch("/api/posts");
const commentsPromise = fetch("/api/comments");
const user = await userPromise;
const posts = await postsPromise;
const comments = await commentsPromise;
return { user, posts, comments };
}
Total time: the slowest of the three. The requests are issued the moment fetch is called — await later just waits for the result.
Even cleaner, with Promise.all:
async function loadAllParallel() {
const [user, posts, comments] = await Promise.all([
fetch("/api/user"),
fetch("/api/posts"),
fetch("/api/comments"),
]);
return { user, posts, comments };
}
The rule of thumb: don’t await inside a loop unless you have to. Many “slow” async functions in the wild are just a for loop with an await on each iteration, when a Promise.all over a .map would run everything at once.
// Slow — N requests in series
for (const id of ids) {
const user = await loadUser(id);
console.log(user);
}
// Fast — N requests in parallel
const users = await Promise.all(ids.map((id) => loadUser(id)));
for (const user of users) {
console.log(user);
}
The serial form is right when each step depends on the previous one. The parallel form is right when they’re independent.
Try it yourself. Write a pause(ms, value) helper that returns a promise resolving to value after ms ms. Then write two async functions: one that calls pause(500, "a"), pause(500, "b"), pause(500, "c") sequentially with await, and one that runs all three in parallel with Promise.all. Time each.
A worked fetch example
A realistic flow — load a user, then their posts (which depend on the user), then their comments (which depend on the posts), with all errors funnelled to one handler.
async function getUserWithActivity(userId) {
try {
const userResponse = await fetch(`/api/users/${userId}`);
if (!userResponse.ok) throw new Error(`User ${userId} not found`);
const user = await userResponse.json();
const [postsResponse, commentsResponse] = await Promise.all([
fetch(`/api/users/${userId}/posts`),
fetch(`/api/users/${userId}/comments`),
]);
const posts = await postsResponse.json();
const comments = await commentsResponse.json();
return { user, posts, comments };
} catch (err) {
console.error("Failed to load activity:", err.message);
throw err;
}
}
async function display(userId) {
const data = await getUserWithActivity(userId);
console.log(`${data.user.name} has ${data.posts.length} posts and ${data.comments.length} comments`);
}
display(1).catch((err) => {
// last line of defence — rejection from display itself
console.error("Display failed:", err);
});
Three things worth noticing:
- The user request must finish first (we need its id), so it’s awaited alone.
- Posts and comments are independent, so they go in parallel via
Promise.all. - Any error — bad response, JSON parse failure, network drop — is caught once at the top.
async functions and arrays
The for...of loop awaits cleanly:
async function processAll(items) {
for (const item of items) {
await processOne(item); // serial — each waits for previous
}
}
But forEach does not await. If you pass an async function to forEach, the loop returns immediately and the async work runs in the background, uncoordinated:
items.forEach(async (item) => {
await processOne(item); // ⚠ no overall await
});
This is a common bug. Use for...of for serial work, or Promise.all(map(...)) for parallel.
Top-level await
Inside an ECMAScript module (type: "module" in Node, <script type="module"> in browsers), await is allowed at the top level — no enclosing async function required:
// inside a module
const response = await fetch("/api/config");
const config = await response.json();
console.log(config);
This is convenient for module initialization and for scripts that do their work at startup. It is not available in classic scripts or in CommonJS files (require-based Node code). If you see SyntaxError: await is only valid in async functions, you’re in one of those environments — wrap your code in an async IIFE:
(async () => {
const data = await loadData();
console.log(data);
})();
What async/await is not
A few clarifications that save confusion later.
- It is not multi-threading. JavaScript still has one thread;
awaitjust suspends the current function, freeing the thread to do other things. - It does not make a synchronous API asynchronous. If a function returns a plain value,
awaiton it is harmless but pointless. - It does not parallelize on its own. Two
awaitcalls in sequence run in sequence. You parallelize by issuing promises first.
async and await are syntactic sugar over the promises you already know. Anything you can do with promises, you can do with async functions — usually with less code.
Try it yourself. Convert this promise chain into an async function with try/catch:
fetch("/api/posts")
.then((r) => r.json())
.then((posts) => posts.filter((p) => p.likes > 10))
.then((popular) => console.log(popular.length))
.catch((err) => console.error(err));Then add a second fetch (/api/users) that runs in parallel with the first using Promise.all.
Common pitfalls
A short list to internalize.
Forgetting async on the function
function loadUser() {
const user = await fetch("/api/user"); // SyntaxError
}
await is only valid inside an async function (or at top level in a module).
Forgetting await
async function loadUser() {
const user = fetch("/api/user"); // user is a Promise, not the response
console.log(user.status); // undefined
}
If a value seems wrong, check whether you forgot an await.
Awaiting inside a loop when you don’t need to
Already covered above — the single biggest perf surprise.
Ignoring an async function’s promise
saveUser(user); // returns a promise — if it rejects, nothing catches it
Either await it, attach a .catch, or let an outer caller handle it.
Recap
You now know:
- An
asyncfunction always returns a promise awaitpauses inside an async function until the promise settles- A rejected awaited promise throws, so use
try/catchfor errors - Sequential awaits run one after another — slow for independent work
- Parallelize with
Promise.allover an array of promises - Don’t pass an async function to
forEach— usefor...oforPromise.all(map) - Top-level
awaitworks in ECMAScript modules
Next steps
You now have a complete grounding in modern JavaScript — variables, types, control flow, arrays, objects, functions, and asynchronous code. From here, the natural next stops are working with the DOM in the browser, building HTTP servers in Node, or picking up a framework. Whichever you choose, the building blocks from this series are the ones you’ll use every day.
→ Back to: What is JavaScript?
Questions or feedback? Email codeloomdevv@gmail.com.