Asynchronous JavaScript: Callbacks, Promises, and Async/Await
Master async JavaScript by understanding callbacks, Promises, and async/await — including error handling, parallel execution, and the full Promise API.
Asynchronous programming is at the core of JavaScript. Network requests, file reads, timers, and user interactions are all async. JavaScript has evolved three styles for handling this: callbacks, Promises, and async/await. Each style builds on the previous — understanding why each was introduced makes the progression logical rather than arbitrary.
The Callback Era
Callbacks are the oldest pattern. You pass a function that will be called when the async work finishes. This works well for simple one-step operations, but it was the primary model before Promises existed and it still appears in many APIs today (Node.js filesystem, setTimeout, event listeners).
function fetchUser(id, callback) {
setTimeout(() => {
if (id <= 0) {
callback(new Error("Invalid ID"), null);
} else {
callback(null, { id, name: "Alice" });
}
}, 200);
}
fetchUser(1, (err, user) => {
if (err) {
console.error("Failed:", err.message);
return;
}
console.log("Got user:", user.name);
});
This works, but nesting callbacks creates the infamous callback hell — each async step must be indented inside the previous one, making error handling repetitive and the logic difficult to follow:
// Callback hell — each step nested inside the previous
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getInvoice(orders[0].id, (err, invoice) => {
if (err) return handleError(err);
renderPage(user, orders, invoice); // finally...
});
});
});
Promises
Promises were introduced to solve callback hell. A Promise represents an eventual value — it is in one of three states: pending, fulfilled, or rejected. Once settled, it never changes state. The key benefit is that you can chain .then() calls in a flat, sequential structure rather than nesting.
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id <= 0) {
reject(new Error("Invalid ID"));
} else {
resolve({ id, name: "Alice" });
}
}, 200);
});
}
fetchUser(1)
.then(user => console.log("Got user:", user.name))
.catch(err => console.error("Failed:", err.message))
.finally(() => console.log("Request complete")); // always runs, fulfilled or rejected
Promise Chaining
.then() returns a new Promise, enabling flat chains instead of nested callbacks. Any value returned from .then() is automatically wrapped in a resolved Promise, and a thrown error short-circuits to the nearest .catch().
// Same three-step flow as callback hell — now flat and readable
getUser(userId)
.then(user => getOrders(user.id)) // returns a Promise; chain waits for it
.then(orders => getInvoice(orders[0].id)) // same
.then(invoice => renderPage(invoice))
.catch(err => handleError(err)); // catches any error in the entire chain
Async/Await
async/await is syntax sugar over Promises, introduced in ES2017. It lets you write async code that reads like synchronous code, which eliminates the cognitive overhead of following .then() chains. Under the hood, every async function returns a Promise, and await simply pauses the function until the awaited Promise settles.
async function loadPage(userId) {
try {
const user = await getUser(userId); // pauses here until resolved
const orders = await getOrders(user.id); // then here
const invoice = await getInvoice(orders[0].id);
renderPage(user, orders, invoice);
} catch (err) {
// catches rejections from any of the three awaits above
handleError(err);
}
}
Error Handling with Await
Because async/await looks synchronous, you handle errors with the same try/catch blocks you use for synchronous code. One important nuance: fetch only rejects on network failures — you must manually check response.ok for HTTP error status codes.
async function saveUser(data) {
try {
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
// fetch resolves even for 4xx/5xx — check ok explicitly
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (err) {
console.error("saveUser failed:", err.message);
throw err; // re-throw so the caller can handle it too
}
}
Parallel Execution with Promise.all
A common mistake is awaiting independent async operations sequentially — each one waits for the previous to finish even though they have no dependency on each other. Promise.all runs them in parallel and waits for all to complete, reducing total wait time from the sum of all durations to roughly the duration of the slowest one.
// SLOW: 300ms total — each await blocks the next
async function slowLoad() {
const user = await fetchUser(1); // 100ms
const posts = await fetchPosts(1); // 100ms
const friends = await fetchFriends(1); // 100ms
return { user, posts, friends };
}
// FAST: ~100ms total — all three start simultaneously
async function fastLoad() {
const [user, posts, friends] = await Promise.all([
fetchUser(1),
fetchPosts(1),
fetchFriends(1),
]);
return { user, posts, friends };
}
Promise.all rejects immediately if any promise rejects. Use Promise.allSettled when you want all results regardless of individual failures — for example, when loading optional dashboard widgets where a single failure shouldn’t blank the whole page:
async function loadDashboard(userId) {
const results = await Promise.allSettled([
fetchProfile(userId),
fetchNotifications(userId),
fetchRecommendations(userId),
]);
return results.map(result => {
if (result.status === "fulfilled") return result.value;
console.warn("Widget failed to load:", result.reason.message);
return null; // render a fallback widget instead of crashing
});
}
The Full Promise API
The Promise API provides several combinators for different coordination patterns. Understanding when to reach for each one prevents both over-sequential and over-parallel code.
// Promise.race — resolves/rejects with the first settled promise
// Useful for timeouts: race the real request against a timer
async function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
);
return Promise.race([promise, timeout]);
}
const data = await withTimeout(fetchHeavyData(), 5000);
// Promise.any — resolves with the first fulfilled promise
// Useful for redundancy: try multiple servers and use the fastest
const fastestServer = await Promise.any([
fetch("https://server-a.example.com/ping"),
fetch("https://server-b.example.com/ping"),
fetch("https://server-c.example.com/ping"),
]);
// Rejects only if ALL promises reject (AggregateError)
// Promise.resolve / Promise.reject — create already-settled promises
// Useful for testing, or normalizing sync/async return values
const cached = Promise.resolve({ id: 1, name: "Alice" }); // immediately fulfilled
const failed = Promise.reject(new Error("not found")); // immediately rejected
Common Mistakes
Running async in a forEach — the loop doesn’t wait for the callbacks to finish, so subsequent code runs before the async work is done:
// BUG: forEach ignores the returned Promises — no waiting occurs
userIds.forEach(async (id) => {
const user = await fetchUser(id);
await saveUser(user); // these run concurrently but forEach can't await them
});
// FIX 1: sequential processing — use for...of, which respects await
for (const id of userIds) {
const user = await fetchUser(id);
await saveUser(user);
}
// FIX 2: parallel processing — map returns Promises, then await all of them
await Promise.all(userIds.map(async (id) => {
const user = await fetchUser(id);
await saveUser(user);
}));
Key Takeaways
- Callbacks are simple but nest poorly. Promises flatten the chain.
async/awaitis the modern standard — it reads like synchronous code.- Always handle rejections:
.catch()ortry/catchwith await. - Use
Promise.allfor parallel independent work;Promise.allSettledwhen partial failure is acceptable. fetchonly rejects on network errors — always checkresponse.okfor HTTP errors.