Promises gave us a clean way to deal with values that aren’t ready yet — a response from a server, a file from disk, a timer that hasn’t fired. But once you chain three or four .then() calls together, the code starts to drift sideways, and following the logic gets harder than it should be. async/await fixes that. It’s a thin layer of syntax built directly on top of promises that lets you write asynchronous code in a flat, top-to-bottom shape — the same way you’d write ordinary synchronous code — while everything underneath stays just as non-blocking as before.
This article assumes you’re already comfortable with promises. If the words “pending”, “resolved”, and “rejected” don’t ring a bell yet, read that one first — async/await is sugar over promises, not a replacement for them, and it makes a lot more sense once you’ve seen the thing it’s hiding.
What async/await actually is
Here’s the one idea to hold onto: async/await does not introduce a new way of doing asynchronous work. It’s a new way of writing code that uses promises. Underneath, you’re still dealing with the exact same promise objects. The keywords just let you stop writing .then() chains and write something that reads top to bottom instead.
Two keywords do the whole job:
asyncmarks a function. Anasyncfunction always returns a promise, no matter what’s inside it.awaitpauses anasyncfunction until a promise settles, then hands you back the resolved value.
Compare the same logic written both ways. First with a plain promise chain:
function getUser() {
return fetch("/api/user")
.then((response) => response.json())
.then((user) => {
console.log(user.name);
return user;
});
}
Now with async/await:
async function getUser() {
const response = await fetch("/api/user");
const user = await response.json();
console.log(user.name);
return user;
}
Both functions do exactly the same thing and both return a promise. The second one just reads like normal, sequential code: get the response, parse it, log the name. No nesting, no callbacks, no chain to trace with your eyes.
The async keyword
Putting async in front of a function changes one concrete thing: the function now always returns a promise. Whatever you return from inside it becomes the value that promise resolves to.
async function greet() {
return "Hello";
}
greet().then((message) => console.log(message)); // "Hello"
Even though the body just returns a plain string, calling greet() gives you a promise that resolves to "Hello" — which is why you can call .then() on it. If you return a promise from an async function, it isn’t double-wrapped; JavaScript flattens it for you, so you still get a single promise back.
The flip side: if an async function throws an error, the returned promise rejects with that error instead of resolving. We’ll use that fact in a moment for error handling.
async functions are still promises on the outside
It’s easy to forget that the caller of an async function gets a promise, not the final value. greet() does not give you "Hello" — it gives you a promise that resolves to "Hello". To unwrap it, you either await it (inside another async function) or attach .then(). There’s no way to synchronously “reach inside” and grab the value, because at the moment of the call the value genuinely may not exist yet.
The await keyword
await is the keyword you’ll actually spend your time with. Put it in front of any promise, and it does two things: it pauses the surrounding async function until that promise settles, then it evaluates to the resolved value.
async function loadPrice() {
const response = await fetch("/api/product/1");
const product = await response.json();
return product.price;
}
Read that top to bottom. The first await waits for the network request and stores the response. The second await waits for the body to be parsed as JSON and stores the object. Each line genuinely waits for the one above it to finish before running. That’s the whole appeal: the order you read is the order things happen.
The crucial part — and the thing that surprises people coming from other languages — is that await does not block the rest of your program. While an async function is paused at an await, the JavaScript engine is free to run other code: handle clicks, fire timers, repaint the page. It only suspends that one function, not the whole thread. When the promise settles, the function quietly resumes from where it left off.
You can only use await inside an async function
await is only valid inside a function marked async (or at the top level of a JavaScript module). Drop an await into a regular function and you’ll get a syntax error. So the two keywords travel together: any time you want to await something, the function holding it has to be async. The one modern exception is top-level await inside an ES module, where you can await directly without wrapping it in a function — handy, but only available in module context.
Handling errors with try/catch
Promise chains handle failures with .catch(). With async/await, you get something even more familiar: the regular try/catch block you already use for synchronous code. Because a rejected promise causes await to throw, you can wrap your awaits in a try and catch any failure in one place.
async function getUser() {
try {
const response = await fetch("/api/user");
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const user = await response.json();
return user;
} catch (error) {
console.error("Could not load the user:", error.message);
return null;
}
}
If fetch rejects (say the network is down), or if the response isn’t OK and we throw manually, control jumps straight to the catch block. One block catches every failure across all the awaits inside the try — you don’t need a separate handler per step. If you’ve read the error handling article, this is the same try/catch you already know, now reaching into asynchronous code too.
An unhandled rejection won't crash loudly — but it isn't free
If an async function throws and nobody catches it — no surrounding try/catch, no .catch() on the returned promise — you get an unhandled promise rejection. The browser logs a warning to the console, and in Node.js it can terminate the process depending on the version and settings. The dangerous part is that it often fails quietly in the browser: the bug doesn’t blow up in your face, it just makes a feature silently stop working. Treat every await that can fail as something you’re responsible for catching somewhere up the chain.
Running tasks in parallel with Promise.all
Here’s the most common performance mistake with await, and it’s worth burning into memory. When you write several awaits one after another, each one waits for the previous to finish — they run sequentially:
async function loadDashboard() {
const user = await fetch("/api/user"); // wait 1s
const posts = await fetch("/api/posts"); // then wait 1s
const stats = await fetch("/api/stats"); // then wait 1s
// total: ~3 seconds
}
If those three requests don’t depend on each other, this is wasteful. The second request doesn’t need the first one’s result, so why is it waiting? You’ve accidentally turned three independent tasks into a slow queue.
When tasks are independent, start them all at once and await them together with Promise.all:
async function loadDashboard() {
const [user, posts, stats] = await Promise.all([
fetch("/api/user"),
fetch("/api/posts"),
fetch("/api/stats"),
]);
// total: ~1 second (they run at the same time)
}
Promise.all takes an array of promises, waits for all of them to resolve, and gives you back an array of their results in the same order. Because the three requests are kicked off together rather than one-after-another, the total time is roughly the length of the slowest one, not the sum of all three. On a real dashboard pulling several endpoints, this is the difference between snappy and sluggish.
Sequential when dependent, parallel when independent
The rule of thumb: use sequential await lines only when a later step genuinely needs the result of an earlier one (you can’t fetch a user’s orders until you know the user’s ID). When the tasks don’t depend on each other, fire them in parallel with Promise.all. One caveat: Promise.all rejects as soon as any of its promises rejects. If you’d rather wait for every task to settle and inspect each outcome individually — successes and failures alike — reach for Promise.allSettled instead.
A realistic example
Let’s tie it together with something close to real code: load a user, then load that user’s orders (which genuinely depends on the user), and handle any failure gracefully.
async function loadUserOrders(userId) {
try {
// Step 1 — get the user (we need their data first)
const userResponse = await fetch(`/api/users/${userId}`);
if (!userResponse.ok) {
throw new Error(`User not found (status ${userResponse.status})`);
}
const user = await userResponse.json();
// Step 2 — these two don't depend on each other, so run them together
const [orders, profile] = await Promise.all([
fetch(`/api/users/${userId}/orders`).then((r) => r.json()),
fetch(`/api/users/${userId}/profile`).then((r) => r.json()),
]);
return { name: user.name, orders, profile };
} catch (error) {
console.error("Failed to load user orders:", error.message);
return null;
}
}
// Calling it — remember the result is a promise
loadUserOrders(42).then((data) => {
if (data) {
console.log(`${data.name} has ${data.orders.length} orders`);
}
});
Notice the deliberate mix. The user fetch comes first and on its own, because everything after needs the user. Then orders and profile fetch in parallel with Promise.all, because neither needs the other. One try/catch covers the whole thing. And the caller treats the return value as a promise — because that’s exactly what an async function gives back. This pattern — fetch, branch into parallel where you can, catch once — covers a huge share of the async code you’ll write for real.
Wrapping up
async/await is the style most modern JavaScript reaches for, because it makes asynchronous logic readable without giving up any of the non-blocking behavior underneath:
async/awaitis syntax built on promises — not a separate mechanism. Under the hood you’re still working with the same promise objects.- An
asyncfunction always returns a promise. Whatever youreturnbecomes the resolved value; whatever youthrowbecomes the rejection. awaitpauses the async function until a promise settles and gives you the resolved value — without blocking the rest of the program. It’s only valid inside anasyncfunction (or top-level in a module).- Handle failures with ordinary
try/catch, since a rejected promise makesawaitthrow. Don’t leave rejections unhandled — they can fail silently. - Run independent tasks together with
Promise.allinstead of awaiting them one by one. Sequential when a step depends on the previous; parallel when it doesn’t.
With promises and async/await under your belt, you’re ready for the place you’ll use them most: actually talking to a server. The next article digs into the Fetch API — how to request data from a URL, send data back, and handle the responses — using exactly the async/await patterns you just learned here.