JavaScript async/await: Complete Tutorial Guide
Master async/await in JavaScript from the ground up. Learn how async functions return Promises, how await suspends execution, error handling with try/catch, parallel patterns, and common pitfalls to avoid.
async/await is the most readable syntax for writing asynchronous JavaScript. Introduced in ES2017, it lets you write async code that looks and reads like synchronous code, while remaining non-blocking. Under the hood it is syntax sugar over Promises — every async function returns a Promise, and await pauses the function until a Promise settles.
The async Keyword
Marking a function with async does two things:
- The function always returns a Promise
- You can use
awaitinside it
// Synchronous function
function add(a, b) {
return a + b;
}
console.log(add(1, 2)); // 3
// async function: return value is wrapped in a Promise
async function addAsync(a, b) {
return a + b;
}
addAsync(1, 2).then(console.log); // 3
// If you explicitly return a Promise, it is NOT double-wrapped
async function fetchUser(id) {
return fetch(`/api/users/${id}`).then((r) => r.json());
// Returns the same Promise — not a Promise<Promise<...>>
}Async Functions Always Return a Promise
async function example() {
return 42;
}
const result = example();
console.log(result instanceof Promise); // true
console.log(result); // Promise { 42 }
result.then((v) => console.log(v)); // 42This means async functions are compatible with all Promise APIs — you can .then(), .catch(), await, and pass them to Promise.all.
The await Keyword
await pauses the execution of the surrounding async function until the Promise settles, then returns the resolved value:
async function loadUser(id) {
// Execution pauses here until fetchUser resolves
const user = await fetchUser(id);
// This line runs only after fetchUser resolves
console.log(user.name);
return user;
}What await Actually Does
await promise is equivalent to promise.then(continueHere). The function does not block the thread — control returns to the caller and the rest of the function runs as a microtask once the Promise settles:
console.log("before call");
async function step() {
console.log("before await");
const result = await Promise.resolve("done");
console.log("after await:", result); // Runs as microtask
}
step();
console.log("after call");
// Output:
// "before call"
// "before await"
// "after call" ← main thread continues synchronously
// "after await: done" ← microtask runs after synchronous codeFor deep details on why this ordering occurs, see the JavaScript event loop and microtasks vs macrotasks.
Awaiting Non-Promise Values
await can be used with any value. Non-Promise values are wrapped in Promise.resolve() first:
async function demo() {
const a = await 42; // await 42 = await Promise.resolve(42)
const b = await "hello"; // Immediate
const c = await null; // null
console.log(a, b, c); // 42 "hello" null
}Error Handling
If an awaited Promise rejects, it throws at the point of the await expression. Use try/catch:
async function loadDashboard(userId) {
try {
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id);
return { user, posts };
} catch (error) {
// Catches rejection from fetchUser OR fetchPosts
console.error("Dashboard load failed:", error.message);
return null;
}
}Granular Error Handling
For different handling per operation, use nested try/catch:
async function initApp() {
let config;
try {
config = await loadConfig();
} catch {
config = defaultConfig; // Fall back silently
}
let user;
try {
user = await authenticateUser();
} catch (err) {
redirectToLogin();
return; // Stop initialization
}
renderApp(config, user);
}Inline Error Handling With .catch()
Since async functions return Promises, you can use .catch() directly on the call:
async function fetchData(url) {
const response = await fetch(url);
return response.json();
}
// Option 1: try/catch inside
async function useData() {
try {
const data = await fetchData("/api/data");
process(data);
} catch (err) {
handleError(err);
}
}
// Option 2: .catch() at the call site
fetchData("/api/data")
.then(process)
.catch(handleError);
// Option 3: per-await .catch() for inline fallback
async function useDataWithFallback() {
const data = await fetchData("/api/data").catch(() => defaultData);
process(data); // data is never undefined
}Parallel vs Sequential Execution
The most common async/await performance mistake is accidentally making parallel operations sequential:
// SLOW: sequential — each waits for the previous
async function loadProfileSlow(userId) {
const user = await fetchUser(userId); // 200ms
const posts = await fetchPosts(userId); // 300ms
const friends = await fetchFriends(userId); // 150ms
// Total: 650ms
return { user, posts, friends };
}
// FAST: parallel — all run simultaneously
async function loadProfileFast(userId) {
const [user, posts, friends] = await Promise.all([
fetchUser(userId), // \
fetchPosts(userId), // } all start at the same time
fetchFriends(userId), // /
]);
// Total: 300ms (slowest one)
return { user, posts, friends };
}
// CORRECT approach: use sequential when there's a dependency
async function loadProfileWithDependency(userId) {
const user = await fetchUser(userId); // Must come first
const [posts, friends] = await Promise.all([ // These two are independent
fetchPosts(user.id),
fetchFriends(user.id),
]);
return { user, posts, friends };
}Rule: use await in sequence only when the result of one is needed to make the next call. For independent operations, use Promise.all.
Common Pitfalls
Pitfall 1: await in Loops
const ids = [1, 2, 3, 4, 5];
// WRONG: sequential — each fetch waits for the previous
async function fetchAllSlow(ids) {
const results = [];
for (const id of ids) {
const data = await fetchItem(id); // Each waits!
results.push(data);
}
return results;
}
// CORRECT: parallel with Promise.all
async function fetchAllFast(ids) {
return Promise.all(ids.map((id) => fetchItem(id)));
}
// ALSO CORRECT: for-await with serial processing when order matters
async function processInOrder(ids) {
const results = [];
for (const id of ids) {
const data = await fetchItem(id);
results.push(await processItem(data)); // Must be sequential
}
return results;
}Pitfall 2: Forgetting await
async function saveUser(user) {
await db.save(user); // Correctly awaited
}
async function handleRequest() {
saveUser(updatedUser); // Missing await! Runs in background
sendResponse(200); // Sends response before save might complete
}
// Fix:
async function handleRequestFixed() {
await saveUser(updatedUser);
sendResponse(200);
}Pitfall 3: await in Non-async Callbacks
await only works directly inside an async function. It does NOT work inside regular callbacks:
// WRONG: await inside a non-async callback
async function processItems(items) {
items.forEach((item) => {
const result = await processItem(item); // SyntaxError!
log(result);
});
}
// CORRECT: make the callback async
async function processItems(items) {
items.forEach(async (item) => { // async callback
const result = await processItem(item); // Works, but...
log(result); // forEach does not await these!
});
}
// ACTUALLY CORRECT: use Promise.all + .map()
async function processItems(items) {
await Promise.all(
items.map(async (item) => {
const result = await processItem(item);
log(result);
})
);
}Async Function Signatures
Async can be applied to any function form:
// Function declaration
async function fetchData() { ... }
// Function expression
const fetchData = async function () { ... };
// Arrow function
const fetchData = async () => { ... };
// Method
const api = {
async fetchData() { ... },
};
// Class method
class DataService {
async fetchData() { ... }
}Rune AI
Key Insights
- async functions always return Promises: Even
return 42inside an async function produces a fulfilled Promise — you cannot escape the async context once inside it - await is not blocking: It yields control back to the event loop, resuming the function in a microtask when the awaited Promise settles
- Use Promise.all for independent operations: Sequential
awaiton unrelated operations is a performance anti-pattern — start them all withPromise.alland await the array - try/catch replaces .catch(): An awaited rejection throws at the await point, making standard synchronous-style error handling work correctly in async functions
- await inside forEach does not wait: Use
Promise.all(array.map(async ...))to correctly parallelize async operations over an array
Frequently Asked Questions
Can await be used at the top level?
Does await block the JavaScript thread?
What happens if I await a Promise that never resolves?
Is async/await always better than .then() chaining?
Can I use async/await with older browser APIs like XMLHttpRequest?
Conclusion
async/await is built entirely on Promises — every async function returns a Promise, and await is a microtask-based pause. It makes sequential async code read naturally while preserving the full power of the Promise API for parallel operations. The most important discipline is knowing when to use await serially versus Promise.all in parallel — this decision has a direct impact on performance and correctness.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.