JavaScript async / await: Promises, Syntax, and Common Pitfalls
JavaScript async / await is a cleaner way to write asynchronous code that still uses Promises under the hood. It helps you read network requests, timers, file-like operations in Node.js, and other delayed work in a style that looks more like normal synchronous code.
Quick answer: Mark a function with async to make it return a Promise, then use await inside it to pause execution until another Promise settles. Use try...catch to handle failures.
Difficulty: Beginner to Intermediate
You'll understand this better if you know: basic JavaScript functions, what a Promise is, and how values are assigned to variables.
1. What Is async / await?
async / await is syntax for working with Promises in a more direct style. It does not replace Promises; it makes Promise-based code easier to read and write.
- async goes before a function declaration or function expression.
- await can be used only inside an async function in standard JavaScript code.
- await pauses the function until the Promise settles, then gives you the resolved value.
- If the Promise rejects, await throws an error that you can catch with try...catch.
This pattern is especially useful when one async operation must finish before the next one starts.
2. Why async / await Matters
Before async / await, Promise chains often became long and nested. That made code harder to follow, especially when you needed multiple steps in sequence.
async / await matters because it:
- reduces callback-style nesting in Promise-heavy code,
- makes sequential async logic easier to read,
- fits naturally with try...catch for error handling,
- works with almost every Promise-based API in browsers and Node.js.
It is not always the fastest way to do everything, but it is often the clearest way to express intent.
3. Basic Syntax or Core Idea
The minimal pattern is an async function that uses await on a Promise.
Function declaration
Here is the core shape of the syntax:
async function loadData() {
const result = await fetchData();
return result;
}In this pattern, async makes the function return a Promise automatically. The await expression waits for fetchData() to resolve, then stores the resolved value in result.
What each part means
- async function tells JavaScript that the function may pause and resume.
- await can be placed before any expression that returns a Promise.
- return inside an async function wraps the value in a resolved Promise.
4. Step-by-Step Examples
Example 1: Waiting for a Promise to resolve
Start with a Promise that resolves after a delay. Then wait for it inside an async function.
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function run() {
console.log("Start");
await delay(1000);
console.log("One second later");
}
run();This example shows that await pauses only the run function, not the whole program.
Example 2: Reading a network response with fetch
The browser fetch() API returns a Promise, so it is a natural fit for await.
async function loadProfile() {
const response = await fetch("/api/profile");
const profile = await response.json();
return profile;
}Here, the first await waits for the HTTP response, and the second waits for the JSON body to be parsed.
Example 3: Handling errors with try...catch
When a Promise rejects, await throws an error. Use try...catch to handle that failure.
async function loadUser() {
try {
const response = await fetch("/api/user");
if (!response.ok) {
throw new Error("Request failed");
}
return await response.json();
} catch (error) {
console.error("Could not load user:", error);
return null;
}
}This pattern is useful because it keeps success and failure handling in one place.
Example 4: Sequential versus parallel awaits
Sometimes you want one operation after another. Other times, you want multiple independent Promises to run together.
async function loadDashboard() {
// Sequential: each step waits for the previous one.
const user = await fetchUser();
const settings = await fetchSettings(user.id);
// Parallel: both promises start before waiting for either one.
const [posts, notifications] = await Promise.all([
fetchPosts(),
fetchNotifications()
]);
return { user, settings, posts, notifications };
}This shows an important skill: await is great for sequencing, but independent work is often better with Promise.all.
5. Practical Use Cases
Use async / await when your code needs to wait for asynchronous work that already returns a Promise.
- Loading API data in a browser before rendering a page section.
- Submitting a form and waiting for a server response.
- Reading files in Node.js with Promise-based APIs.
- Running multi-step workflows where each step depends on the previous one.
- Combining several independent requests with Promise.all inside an async function.
If the code does not need to wait for anything, async / await is unnecessary overhead.
6. Common Mistakes
Mistake 1: Using await outside an async function
Many beginners try to use await in a regular function or at the top level of a classic script.
Problem: In ordinary scripts and non-async functions, JavaScript reports a syntax error such as await is only valid in async functions and the top level bodies of modules.
function loadData() {
const response = await fetch("/api/data");
return response;
}Fix: Mark the function as async, or move the code into a module if you need top-level await.
async function loadData() {
const response = await fetch("/api/data");
return response;
}The corrected version works because await is now inside an async function.
Mistake 2: Forgetting that async functions return Promises
An async function does not return a plain value immediately. It always returns a Promise.
Problem: Code that expects a direct value can fail or produce undefined behavior because the function result is still a Promise object.
async function getName() {
return "Ada";
}
const name = getName();
console.log(name.toUpperCase());Fix: Wait for the Promise with await, or use then() if you are outside an async function.
async function showName() {
const name = await getName();
console.log(name.toUpperCase());
}The fixed version works because name now holds the resolved string instead of a Promise.
Mistake 3: Not handling rejected Promises
When a Promise rejects and nobody catches it, the failure becomes an unhandled rejection.
Problem: This often shows up as Uncaught (in promise) in the console, which means the error escaped without a handler.
async function loadData() {
const response = await fetch("/api/missing");
return await response.json();
}
loadData();Fix: Catch errors inside the async function, or handle the returned Promise where you call it.
async function loadData() {
try {
const response = await fetch("/api/missing");
return await response.json();
} catch (error) {
console.error(error);
return null;
}
}
loadData().catch((error) => {
console.error("Unexpected failure:", error);
});The corrected version works because every rejection has a clear place to go.
7. Best Practices
Practice 1: Use async / await for readable sequencing
When one async step depends on the previous result, await makes the dependency obvious.
async function loadOrder(id) {
const order = await fetchOrder(id);
const shipping = await fetchShipping(order.shippingId);
return { order, shipping };
}This reads like a step-by-step recipe, which is easier to maintain than nested callbacks or deeply chained Promises.
Practice 2: Use Promise.all for independent operations
If two requests do not depend on each other, start them together and wait once.
async function loadPageData() {
const [user, notifications] = await Promise.all([
fetchUser(),
fetchNotifications()
]);
return { user, notifications };
}This is usually faster than awaiting each Promise one by one, because both operations can run in parallel.
Practice 3: Keep error handling close to the await
Handle expected failures near the code that can fail. This makes the behavior easier to reason about.
async function saveProfile(profile) {
try {
const response = await fetch("/api/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(profile)
});
if (!response.ok) {
throw new Error("Save failed");
}
return true;
} catch (error) {
console.error("Could not save profile", error);
return false;
}
}This pattern keeps failure handling local and prevents silent rejection bugs.
8. Limitations and Edge Cases
- await does not make code magically parallel; if you write several awaits in a row, they still run one after another.
- await only works with thenable values and Promises; awaiting a plain value returns it immediately.
- An async function always returns a Promise, even if you return a simple value.
- In older scripts, top-level await is not allowed unless the code runs as a module.
- Using await inside Array.prototype.forEach does not wait the way many beginners expect, because forEach does not handle Promises.
Note: If you need ordered async processing over a list, use for...of with await, or collect Promises and combine them with Promise.all.
9. Practical Mini Project
Let’s build a tiny browser example that loads a message and shows success or failure in the page.
<button id="loadButton">Load message</button>
<p id="status">Idle</p>
<script>
const button = document.getElementById("loadButton");
const status = document.getElementById("status");
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function loadMessage() {
try {
status.textContent = "Loading...";
await delay(1000);
status.textContent = "Message loaded successfully.";
} catch (error) {
status.textContent = "Something went wrong.";
console.error(error);
}
}
button.addEventListener("click", loadMessage);
</script>This mini project combines a Promise, an async function, and a user action. It shows the full pattern you will use often in real browser code.
10. Key Points
- async functions always return Promises.
- await pauses only the current async function, not the entire JavaScript runtime.
- Use try...catch to handle rejected Promises cleanly.
- Use Promise.all when tasks can run at the same time.
- Top-level await depends on module support.
11. Practice Exercise
Write an async function that:
- waits for two independent Promises,
- returns both results together,
- handles errors without crashing,
- prints a friendly message if either Promise fails.
Expected output: an object or message showing both resolved values when successful, or an error message when one request fails.
Hint: Use Promise.all for the two independent operations, then wrap the code in try...catch.
function getUser() {
return new Promise((resolve) => {
setTimeout(() => resolve({ id: 1, name: "Ada" }), 300);
});
}
function getSettings() {
return new Promise((resolve) => {
setTimeout(() => resolve({ theme: "dark" }), 400);
});
}
async function loadUserData() {
try {
const [user, settings] = await Promise.all([
getUser(),
getSettings()
]);
return { user, settings };
} catch (error) {
console.error("Failed to load data:", error);
return null;
}
}
loadUserData().then((data) => {
console.log(data);
});This solution works because both Promises start together, and the catch path prevents an unhandled rejection.
12. Final Summary
async / await is a Promise-friendly way to write asynchronous JavaScript that is easier to read than many nested Promise chains. It is best for code that needs to wait for values in sequence, especially when one step depends on the result of the previous step.
Remember that async does not remove asynchrony, and await does not make multiple tasks run in parallel by itself. Use Promise.all for independent work, and use try...catch to keep errors under control.
If you want to go further, the next useful topic is Promises themselves, especially Promise.all, rejection handling, and how async functions behave when they return values or throw errors.