Asynchronous JavaScript: From Callback Hell to Clean Async/Await
This post walks through how JavaScript evolved its async patterns from the callback chaos of the early days to the clean, readable async/await we use today. No fancy jargon, just practical examples showing what each pattern looks like and why we moved on.
Part 1: The Callback Era
JavaScript is single-threaded. Callbacks were the first solution: "Hey, go do this thing, and when you're done, run this function I gave you."
function fetchUser(userId, callback) {
setTimeout(() => {
const user = { id: userId, name: "Alex" };
callback(user);
}, 1000);
}
fetchUser(1, (user) => {
console.log("Got user:", user.name);
});
You pass a function that gets executed later. This works fine for one-off async operations. But real applications aren't one-off. You need to fetch a user, then their posts, then the comments on those posts, then the author of each comment etc etc.
// wohooooo
fetchUser(1, (user) => {
fetchPosts(user.id, (posts) => {
fetchComments(posts[0].id, (comments) => {
fetchAuthor(comments[0].authorId, (author) => {
fetchAuthorProfile(author.id, (profile) => {
console.log("Finally got the profile:", profile);
}, (error) => {
console.error("Profile fetch failed:", error);
});
}, (error) => {
console.error("Author fetch failed:", error);
});
}, (error) => {
console.error("Comments fetch failed:", error);
});
}, (error) => {
console.error("Posts fetch failed:", error);
});
}, (error) => {
console.error("User fetch failed:", error);
});
Every async operation adds another layer of indentation. By the time you're five levels deep, you're scrolling horizontally(from doom scrolling reels to scrolling callbacks) just to read your own code. And notice how error handling is repetitive? Every callback needs its own error handler, and they're all over the place.
Part 2: Promises
Promises arrived with ES6 (2015) and changed the game. Instead of passing callbacks into functions, functions now return a "promise" an object representing a future value(I promise I’m writing this myself).\
What Is a Promise?
Think of a promise like ordering at a restaurant. You place your order (start the async operation), you get a receipt (the promise), and eventually the food arrives (the promise resolves) or they tell you they're out of ingredients (the promise rejects).
function fetchUser(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (userId <= 0) {
reject(new Error("Invalid user ID"));
return;
}
const user = { id: userId, name: "Alex" };
resolve(user);
}, 1000);
});
}
fetchUser(1)
.then(user => {
console.log("Got user:", user.name);
})
.catch(error => {
console.error("Failed:", error.message);
});
The .then() method returns another promise, so you can chain them. For instance
fetchUser(1)
.then(user => {
console.log("1. Got user:", user.name);
return fetchPosts(user.id);
})
.then(posts => {
console.log("2. Got posts:", posts.length);
return fetchComments(posts[0].id);
})
.then(comments => {
console.log("3. Got comments:", comments.length);
return fetchAuthor(comments[0].authorId);
})
.then(author => {
console.log("4. Got author:", author.name);
return fetchAuthorProfile(author.id);
})
.then(profile => {
console.log("5. Finally got profile:", profile);
})
.catch(error => {
console.error("Something failed along the way:", error.message);
});
the single .catch() at the end If any promise in the chain rejects, it skips straight to the error handler. No more duplicating error logic everywhere.
Promise.all ( Parallel Execution )
Promise.all([
fetchUser(1),
fetchUserSettings(1)
])
.then(([user, settings]) => {
console.log("User:", user);
console.log("Settings:", settings);
})
.catch(error => {
console.error("Failed to load data:", error);
});
Part 3: Async/Await
ES2017 introduced async and await, The idea is simple: write asynchronous code that looks like regular synchronous code. Mark a function as async, and you can use await inside it to pause execution until a promise resolves -
async function loadUserData() {
try {
const user = await fetchUser(1);
console.log("1. Got user:", user.name);
const posts = await fetchPosts(user.id);
console.log("2. Got posts:", posts.length);
const comments = await fetchComments(posts[0].id);
console.log("3. Got comments:", comments.length);
const author = await fetchAuthor(comments[0].authorId);
console.log("4. Got author:", author.name);
const profile = await fetchAuthorProfile(author.id);
console.log("5. Finally got profile:", profile);
return profile;
} catch (error) {
console.error("Something failed:", error.message);
throw error;
}
}
Same five-step process as before, but it reads like normal JavaScript. No chains, no nesting, just top-to- bottom execution that happens to pause and wait for async operations.
Error Handling Patterns
This is where async/await really proves its worth. You get to use the same error handling patterns you use everywhere else in JavaScript.
Try/Catch
async function getDashboardData(userId) {
try {
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id);
const notifications = await fetchNotifications(user.id);
return { user, posts, notifications };
} catch (error) {
console.error("Dashboard failed to load:", error);
showErrorMessageToUser("We couldn't load your dashboard. Try again?");
return null;
}
}
Sometimes you want to handle specific steps differently
async function getDashboardData(userId) {
let user;
try {
user = await fetchUser(userId);
} catch (error) {
console.error("Authentication failed:", error);
redirectToLogin();
return;
}
let posts = [];
let notifications = [];
try {
posts = await fetchPosts(user.id);
} catch (error) {
console.warn("Couldn't load posts, continuing without them");
}
try {
notifications = await fetchNotifications(user.id);
} catch (error) {
console.warn("Couldn't load notifications, continuing without them");
}
return { user, posts, notifications };
}
Retry Logic
lets now see how async/await makes retry logic reader
async function fetchWithRetry(url, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (attempt === maxAttempts) {
throw new Error(`Failed after ${maxAttempts} attempts: ${error.message}`);
}
console.log(`Attempt ${attempt} failed, retrying...`);
await delay(1000 * attempt);
}
}
}
Try/Catch, It's the most readable for 95% of cases. Just remember that async functions always return promises, so you're not escaping the promise world you're just getting better syntax for it.
JavaScript's async evolution mirrors how we think about programming. We started with "do this, then tell me when you're done" (callbacks). We moved to "here's a placeholder for the future value" (promises). And we landed on "write it like it happens, let the runtime handle the waiting" (async/await). and that all happened in less than two decades.
Writing this was a fun journey, this was all code based, and I feel thats quite right for these concepts.
Next one is gonna be my favourite, and again very code heavy.