JavaScript Promises and Async/Await: The Complete Tutorial
Master asynchronous JavaScript — from callbacks to Promises to async/await. Error handling, parallel execution, common patterns, and real-world examples.
The Problem: Asynchronous Code
JavaScript is single-threaded. It can only do one thing at a time. But web applications need to fetch data from APIs, read files, query databases, and handle user input — all without freezing the UI.
This is where asynchronous programming comes in. Instead of waiting for a slow operation to complete, JavaScript starts the operation and moves on. When the result is ready, a callback function handles it.
The Evolution
Callbacks (1995) → Promises (ES2015) → Async/Await (ES2017)
Each step made async code easier to read and write.
Callbacks: The Original Approach
// Callback pattern
function fetchUser(id, callback) {
setTimeout(() => {
callback(null, { id, name: "Alice" });
}, 1000);
}
fetchUser(1, (error, user) => {
if (error) {
console.error(error);
return;
}
console.log(user);
});
Callback Hell
The problem with callbacks is nesting. When you need sequential async operations, you get the "pyramid of doom":
fetchUser(1, (err, user) => {
fetchOrders(user.id, (err, orders) => {
fetchOrderDetails(orders[0].id, (err, details) => {
fetchShippingInfo(details.shippingId, (err, shipping) => {
// 4 levels deep... and it gets worse
console.log(shipping);
});
});
});
});
This is unreadable, hard to debug, and error handling is a nightmare.
Promises: The Solution
A Promise is an object that represents the eventual completion (or failure) of an async operation. It has three states:
- Pending — Initial state, operation in progress
- Fulfilled — Operation completed successfully
- Rejected — Operation failed
// Creating a Promise
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
const success = true;
if (success) {
resolve({ id: 1, name: "Alice" });
} else {
reject(new Error("Failed to fetch user"));
}
}, 1000);
});
// Consuming a Promise
promise
.then((user) => console.log(user))
.catch((error) => console.error(error))
.finally(() => console.log("Done"));
Chaining Promises
The real power of Promises is chaining. Each .then() returns a new Promise:
fetchUser(1)
.then((user) => fetchOrders(user.id))
.then((orders) => fetchOrderDetails(orders[0].id))
.then((details) => fetchShippingInfo(details.shippingId))
.then((shipping) => console.log(shipping))
.catch((error) => console.error(error)); // Catches ANY error in the chain
Compare this to the callback hell version — it's flat, readable, and has centralized error handling.
Promise.all — Parallel Execution
When you need multiple async operations that don't depend on each other, run them in parallel:
const [user, posts, notifications] = await Promise.all([
fetchUser(1),
fetchPosts(1),
fetchNotifications(1),
]);
// All three run simultaneously — total time = slowest one, not sum of all
Important: If ANY promise rejects, Promise.all rejects immediately. Use Promise.allSettled if you want all results regardless of failures.
Promise.allSettled
const results = await Promise.allSettled([
fetchUser(1), // succeeds
fetchUser(999), // fails
fetchUser(2), // succeeds
]);
results.forEach((result) => {
if (result.status === "fulfilled") {
console.log("Success:", result.value);
} else {
console.log("Failed:", result.reason);
}
});
Promise.race
Returns the result of the first promise to settle (fulfill or reject):
// Timeout pattern
const result = await Promise.race([
fetchData(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), 5000)
),
]);
Promise.any
Returns the first promise to fulfill (ignores rejections):
// Try multiple sources, use whichever responds first
const data = await Promise.any([
fetchFromCDN1(),
fetchFromCDN2(),
fetchFromOrigin(),
]);
Async/Await: The Modern Way
async/await is syntactic sugar over Promises. It makes async code look and behave like synchronous code:
// Without async/await
function getUser(id) {
return fetch(`/api/users/${id}`)
.then((response) => response.json())
.then((data) => data);
}
// With async/await
async function getUser(id) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return data;
}
Rules of Async/Await
awaitcan only be used inside anasyncfunction (or at the top level of a module)asyncfunctions always return a Promiseawaitpauses execution until the Promise settles
Error Handling with Try/Catch
async function getUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to fetch user:", error.message);
throw error; // Re-throw if you want callers to handle it
}
}
Sequential vs Parallel
// ❌ Sequential — each waits for the previous one (slow)
const user = await fetchUser(1);
const posts = await fetchPosts(1);
const comments = await fetchComments(1);
// Total time: fetchUser + fetchPosts + fetchComments
// ✅ Parallel — all run at the same time (fast)
const [user, posts, comments] = await Promise.all([
fetchUser(1),
fetchPosts(1),
fetchComments(1),
]);
// Total time: max(fetchUser, fetchPosts, fetchComments)
Async Iteration
// Process items one at a time
async function processItems(items) {
for (const item of items) {
await processItem(item); // Sequential
}
}
// Process items in parallel with concurrency limit
async function processWithLimit(items, limit = 5) {
const chunks = [];
for (let i = 0; i < items.length; i += limit) {
chunks.push(items.slice(i, i + limit));
}
for (const chunk of chunks) {
await Promise.all(chunk.map(processItem));
}
}
Real-World Patterns
Fetch with Retry
async function fetchWithRetry(url, options = {}, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (i === retries - 1) throw error;
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, i))); // Exponential backoff
}
}
}
Debounced Async Search
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
return new Promise((resolve) => {
timer = setTimeout(async () => {
resolve(await fn(...args));
}, delay);
});
};
}
const debouncedSearch = debounce(async (query) => {
const response = await fetch(`/api/search?q=${query}`);
return response.json();
}, 300);
Loading State Pattern
function useAsync(asyncFn) {
const [state, setState] = useState({
data: null,
loading: false,
error: null,
});
const execute = async (...args) => {
setState({ data: null, loading: true, error: null });
try {
const data = await asyncFn(...args);
setState({ data, loading: false, error: null });
return data;
} catch (error) {
setState({ data: null, loading: false, error });
throw error;
}
};
return { ...state, execute };
}
Common Mistakes
1. Forgetting to await
// ❌ Bug: response is a Promise, not the actual response
const response = fetch("/api/data");
console.log(response); // Promise { <pending> }
// ✅ Correct
const response = await fetch("/api/data");
2. Using await in a loop when parallel is possible
// ❌ Slow: sequential
for (const id of ids) {
const user = await fetchUser(id);
users.push(user);
}
// ✅ Fast: parallel
const users = await Promise.all(ids.map(fetchUser));
3. Swallowing errors
// ❌ Error is silently ignored
async function getData() {
try {
return await fetchData();
} catch (error) {
// Nothing here — error disappears
}
}
// ✅ Handle or re-throw
async function getData() {
try {
return await fetchData();
} catch (error) {
console.error("Failed:", error);
return null; // Explicit fallback
}
}
Quick Reference
| Method | Behavior | Use When |
|---|---|---|
Promise.all | Fails fast on first rejection | All must succeed |
Promise.allSettled | Waits for all, reports each | Need all results |
Promise.race | First to settle wins | Timeout patterns |
Promise.any | First to fulfill wins | Fallback sources |
await | Pauses until resolved | Sequential flow |
Related Tools
- JSON Formatter — Format API response data
- Regex Tester — Test patterns for data validation
- cURL to Code — Convert API calls to JavaScript fetch
- JWT Decoder — Decode authentication tokens
- Base64 Encoder — Encode/decode API payloads