Callbacks led to callback hell. Promises made async code chainable but still verbose. Async/await — introduced in ES2017 — lets you write asynchronous code that reads like synchronous code, without blocking the thread.
This guide covers everything: how async/await works under the hood, error handling, parallel execution, and pitfalls to avoid.
The Problem Async/Await Solves
Before async/await, fetching data looked like this:
// Callback hell
getUserId(function(id) {
getProfile(id, function(profile) {
getPosts(profile.userId, function(posts) {
render(posts);
});
});
});
// Promises (better, but still chained)
getUserId()
.then(id => getProfile(id))
.then(profile => getPosts(profile.userId))
.then(posts => render(posts))
.catch(err => console.error(err));
With async/await:
async function loadUserPosts() {
const id = await getUserId();
const profile = await getProfile(id);
const posts = await getPosts(profile.userId);
render(posts);
}
Same logic, reads top-to-bottom like synchronous code.
How Async/Await Works
The async keyword
async before a function declaration makes it return a Promise, always.
async function greet() {
return "hello";
}
greet(); // Promise { 'hello' }
greet().then(console.log); // "hello"
Even if you return a plain value, async wraps it in Promise.resolve().
The await keyword
await pauses execution inside an async function until the Promise resolves. The thread is not blocked — other code runs while waiting.
async function example() {
console.log("start");
const result = await fetch("https://api.example.com/data");
console.log("end"); // runs only after fetch resolves
}
await can only be used inside an async function (or at the top level of an ES module).
Quick reference
| Syntax | What it does |
|---|---|
async function foo() {} |
Returns a Promise |
const x = await promise |
Waits for promise to resolve, assigns value |
await Promise.resolve(42) |
Immediately resolves to 42 |
await new Promise(r => setTimeout(r, 1000)) |
Waits 1 second |
Top-level await |
Supported in ES modules (.mjs or type: "module") |
Error Handling
try/catch
The most readable way — mirrors synchronous error handling:
async function fetchUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const user = await response.json();
return user;
} catch (err) {
console.error("Failed to fetch user:", err.message);
return null;
}
}
.catch() on the call
If you don't want to wrap every call in try/catch:
const user = await fetchUser(1).catch(err => {
console.error(err);
return null;
});
await with a fallback
const data = await riskyOperation().catch(() => defaultValue);
Error handling patterns comparison
| Pattern | When to use |
|---|---|
try/catch inside async fn |
Most common, cleanest for sequential logic |
.catch() on caller |
When you want a fallback without try/catch boilerplate |
Promise.allSettled |
When you want all results, including failures |
Custom to() helper |
When you prefer Go-style [err, result] tuples |
Real-World Example: Fetch API
async function getWeather(city) {
const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=YOUR_KEY`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Weather API error: ${response.status}`);
}
const data = await response.json();
return {
city: data.name,
temp: Math.round(data.main.temp - 273.15), // Kelvin → Celsius
description: data.weather[0].description,
};
} catch (err) {
if (err.name === "TypeError") {
throw new Error("Network error — check your connection");
}
throw err;
}
}
// Usage
const weather = await getWeather("London");
console.log(`${weather.city}: ${weather.temp}°C, ${weather.description}`);
Parallel Execution
The sequential trap
This is slower than it needs to be:
// BAD: runs sequentially — total wait = 3s if each takes 1s
const users = await getUsers(); // wait 1s
const posts = await getPosts(); // wait 1s
const comments = await getComments(); // wait 1s
Promise.all — run in parallel
// GOOD: all three fire simultaneously — total wait ≈ 1s
const [users, posts, comments] = await Promise.all([
getUsers(),
getPosts(),
getComments(),
]);
Promise.all rejects immediately if any promise rejects. Use it when you need all results and a single failure should abort.
Promise.allSettled — get all results regardless
const results = await Promise.allSettled([
fetchProfile(userId),
fetchPosts(userId),
fetchFollowers(userId),
]);
for (const result of results) {
if (result.status === "fulfilled") {
console.log("Got:", result.value);
} else {
console.error("Failed:", result.reason);
}
}
Promise.race — first one wins
// Timeout pattern: reject if fetch takes more than 5s
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
return await response.json();
} catch (err) {
if (err.name === "AbortError") throw new Error("Request timed out");
throw err;
}
Promise.any — first success wins
// Try multiple CDN mirrors, use whichever responds first
const data = await Promise.any([
fetch("https://cdn1.example.com/data.json"),
fetch("https://cdn2.example.com/data.json"),
fetch("https://cdn3.example.com/data.json"),
]).then(r => r.json());
Async Loops
Sequential loop (each waits for previous)
const userIds = [1, 2, 3, 4, 5];
for (const id of userIds) {
const user = await getUser(id); // waits before next iteration
console.log(user.name);
}
Parallel loop (all at once)
// Fire all requests simultaneously
const users = await Promise.all(userIds.map(id => getUser(id)));
Batched parallel (n at a time)
async function batchProcess(items, batchSize, fn) {
const results = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(fn));
results.push(...batchResults);
}
return results;
}
// Process 100 users, 10 at a time
const users = await batchProcess(userIds, 10, getUser);
forEach doesn't work with await
// BUG: forEach doesn't await — all run and you get no results
items.forEach(async (item) => {
await processItem(item); // this is ignored by forEach
});
// FIX: use for...of
for (const item of items) {
await processItem(item);
}
Async/Await in Different Contexts
Node.js (CommonJS)
const https = require("https");
const { promisify } = require("util");
// Promisify callback-based APIs
const readFile = promisify(require("fs").readFile);
async function readConfig() {
const content = await readFile("config.json", "utf8");
return JSON.parse(content);
}
// Top-level: wrap in IIFE or use async main
(async () => {
const config = await readConfig();
console.log(config);
})();
Node.js (ES modules)
// package.json: "type": "module" OR filename .mjs
import { readFile } from "fs/promises";
// Top-level await works here
const config = JSON.parse(await readFile("config.json", "utf8"));
React (useEffect)
// Can't make useEffect itself async — use inner function
useEffect(() => {
let cancelled = false;
async function loadData() {
try {
const data = await fetchData(id);
if (!cancelled) setData(data);
} catch (err) {
if (!cancelled) setError(err.message);
}
}
loadData();
return () => { cancelled = true; }; // cleanup
}, [id]);
TypeScript
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<User>;
}
Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
await outside async function |
SyntaxError | Use async on enclosing function, or top-level await in ESM |
Sequential await when parallel is possible |
Slow — waits unnecessarily | Use Promise.all for independent calls |
forEach with await |
await is ignored |
Use for...of or Promise.all(arr.map(...)) |
Not checking response.ok after fetch |
Silent failures with 4xx/5xx | Always check response.ok before .json() |
| Swallowing errors in catch | Hides bugs | Log and rethrow, or return a typed error |
Missing await on a Promise |
Gets Promise object instead of value | Add await; TypeScript catches this with strict mode |
| Unhandled rejected Promise | Node.js crash / silent browser failure | Always add .catch() or try/catch |
Async/Await vs Promises vs Callbacks
| Feature | Callbacks | Promises | Async/Await |
|---|---|---|---|
| Readability | Poor (nesting) | OK (chaining) | Best (linear) |
| Error handling | Manual | .catch() |
try/catch |
| Debugging (stack traces) | Poor | Moderate | Good |
| Parallel execution | Complex | Promise.all |
await Promise.all |
| Available since | ES5 | ES6 (2015) | ES2017 |
| Top-level usage | Yes | Yes | ESM only |
Async/await is Promises — it compiles to Promise chains. You can mix both freely.
FAQ
Can I use await at the top level?
Yes, in ES modules (files with .mjs extension, or "type": "module" in package.json). In CommonJS you still need to wrap in an async IIFE.
Does await block the event loop?
No. await suspends only the current async function. The event loop continues to process other tasks while waiting.
What happens if I forget await?
The expression evaluates to a Promise object instead of the resolved value. TypeScript with strictNullChecks and @typescript-eslint/no-floating-promises catches this automatically.
How do I set a timeout on an await?
Use AbortController with fetch, or Promise.race with a rejection timeout: await Promise.race([myPromise, new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 5000))]).
Can async functions return multiple values?
Return an object or array: return { user, posts } then const { user, posts } = await loadAll().
Is async/await supported everywhere?
Yes — all modern browsers (Chrome 55+, Firefox 52+, Safari 10.1+, Edge 15+) and Node.js 7.6+. For older environments, Babel transpiles it to generator functions.