Async JavaScript Explained — Part 2
Mastering Promises & Modern Async JavaScript

Welcome back! In Part 1, we covered callbacks, callback hell, and the basics of Promises. Now we'll dive deeper into advanced Promise techniques and modern async/await syntax that will transform how you write asynchronous JavaScript.
Recap: Promise Basics
Remember, a Promise has three states:
Pending: Initial state, operation is ongoing
Fulfilled: Operation completed successfully
Rejected: Operation failed
Let's build on this foundation.
Promise Chaining: The Deep Dive
Promise Chaining is the technique of performing multiple asynchronous operations in sequence, where each operation starts when the previous one completes.
Why Chain Promises?
Imagine you need to:
Fetch user data from a server
Use that user ID to fetch their posts
Use the first post ID to fetch comments
Each step depends on the previous one. Promise chaining makes this elegant and readable.
Basic Promise Chaining
// Simple example: Each step returns a value
Promise.resolve(5)
.then(number => {
console.log("Starting with:", number); // 5
return number * 2; // Return a value
})
.then(number => {
console.log("After doubling:", number); // 10
return number + 3; // Return another value
})
.then(number => {
console.log("After adding 3:", number); // 13
return number;
})
.catch(error => {
console.log("Error:", error);
});
.then() becomes the value passed to the next .then().Chaining Asynchronous Operations
// Simulating database operations...
function getUser(userId) {
return new Promise((resolve , reject) => {
setTimeout(() => {
console.log("Fetching user...");
if (userId) {
resolve({ id: userId , name: "Bob" , age: 28 });
}
else {
reject("User ID is required!");
}
} , 1000);
});
}
function getUserPosts(userId) {
return new Promise((resolve , reject) => {
setTimeout(() => {
console.log("Fetching posts for user: " , userId);
resolve([
{ id: 1 , title: "First Post" , userId: userId },
{ id: 2 , title: "Second Post" , userId: userId }
]);
} , 1000);
});
}
function getPostComments(postId) {
return new Promise((resolve , reject) => {
setTimeout(() => {
console.log("Fetching comments for post: " , postId);
resolve([
{id: 1 , text: "Great Post!" , postId: postId},
{id: 2 , text: "Thanks for sharing!" , postId: postId}
]);
} , 1000);
});
}
// Chaining these operations together...
getUser(123)
.then(user => {
console.log("User found: " , user.name);
return getUserPosts(user.id);
})
.then(posts => {
console.log("Found" , posts.length , "posts");
// Get comments for the first post...
return getPostComments(posts[0].id);
})
.then(comments => {
console.log("Comments: " , comments);
})
.catch(err => {
console.log("Something went wrong!" , err);
});
.then(), the next .then() waits for that Promise to resolve before executing.Common Promise Chaining Mistakes
// MISTAKE 1: Forgetting to return...
getUser(123)
.then(user => {
getUserPosts(user.id); // Missing return!
})
.then(posts => {
console.log(posts); // undefined! The promise wasn't returned...
});
// CORRECT: Always return...
getUser(123)
.then(user => {
return getUserPosts(user.id); // Return the promise
})
.then(posts => {
console.log(posts); // Now posts is defined...
});
// MISTAKE 2: Nesting instead of chaining (creating callback hell again!)
getUser(123)
.then(user => {
getUserPosts(user.id).then(posts => {
getPostComments(posts[0].id).then(comments => {
console.log(comments);
});
});
});
// CORRECT: Flat chaining...
getUser(123)
.then(user => getUserPosts(user.id))
.then(posts => getPostComments(posts[0].id))
.then(comments => console.log(comments));
Async/Await: The Modern Way
async/await is syntactic sugar over Promises that makes asynchronous code look & behave like synchronous code. It's easier to read & write.
The async keyword
The async keyword before a function means 2 things:
The function always returns a Promise
You can use
awaitinside it
// Regular function...
function regularFunction() {
return "Hello";
}
// Async function...
async function asyncFunction() {
return "Hello"; // Automatically wrapped in a Promise
}
// Both of these work the same way:
regularFunction(); // Returns "Hello"
asyncFunction(); // Returns Promise that resolves to "Hello"
// Using the async function
asyncFunction().then(result => {
console.log(result); // "Hello"
});
The await keyword
await makes JavaScript wait until a Promise is resolved & returns its result. You can only use await inside an async function (most probably!).
// Without await... (using .then)
function getDataOldWay() {
getUser(123)
.then(user => {
console.log(user);
});
}
// With await... (much cleaner!)
async function getDataNewWay() {
const user = await getUser(123);
console.log(user);
}
Think of await as a pause button: The function pauses at the await line until the Promise resolves, then continues with the resolved value.
Rewriting Promise Chains with async/await
// Promise chain version...
function fetchAllData() {
getUser(123)
.then(user => {
console.log("User:", user.name);
return getUserPosts(user.id);
})
.then(posts => {
console.log("Posts:", posts.length);
return getPostComments(posts[0].id);
})
.then(comments => {
console.log("Comments:", comments);
})
.catch(error => {
console.log("Error:", error);
});
}
// async/await version... (much more readable!)
async function fetchAllDataAsync() {
try {
const user = await getUser(123);
console.log("User:", user.name);
const posts = await getUserPosts(user.id);
console.log("Posts:", posts.length);
const comments = await getPostComments(posts[0].id);
console.log("Comments:", comments);
}
catch (error) {
console.log("Error:", error);
}
}
Look how much cleaner this is! The async/await version reads like synchronous code, making it easier to understand the flow.
async/await with Multiple Operations
async function getUserProfile() {
// Sequential execution (one after another)
const user = await getUser(123);
const posts = await getUserPosts(user.id);
const comments = await getPostComments(posts[0].id);
return {
userName: user.name,
totalPosts: posts.length,
firstPostComments: comments
};
}
getUserProfile().then(profile => {
console.log(profile);
});
// Or with async/await in another async function
async function displayProfile() {
const profile = await getUserProfile();
console.log(profile);
}
Parallel Execution with Promise.all()
When operations don't depend on each other, run them in parallel for better performance:
// Sequential (slower - waits for each to complete)
async function getDataSequential() {
const user1 = await getUser(1); // Wait 1 second
const user2 = await getUser(2); // Wait another 1 second
const user3 = await getUser(3); // Wait another 1 second
// Total: 3 seconds
return [user1, user2, user3];
}
// Parallel (faster - all run at once)
async function getDataParallel() {
const [user1, user2, user3] = await Promise.all([
getUser(1),
getUser(2),
getUser(3)
]);
// Total: 1 second (they run simultaneously)
return [user1, user2, user3];
}
Promise.all() takes an array of Promises and returns a single Promise that resolves when all input Promises resolve. If any Promise rejects, the entire Promise.all() rejects.Promise.allSettled() - Wait for All, Regardless of Outcome
Unlike Promise.all(), Promise.allSettled() waits for all Promises to complete, whether they succeed or fail:
async function fetchUsersWithResults() {
const results = await Promise.allSettled([
getUser(1),
getUser(999), // This might fail
getUser(2)
]);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`User ${index + 1}:`, result.value);
}
else {
console.log(`User ${index + 1} failed:`, result.reason);
}
});
}
Promise.race() - First One Wins
Promise.race() returns the result of the first Promise that settles (either resolves or rejects):
async function getFirstResponse() {
try {
// Whichever completes first wins
const result = await Promise.race([
getUser(1),
getUser(2),
getUser(3)
]);
console.log("First response:", result);
}
catch (error) {
console.log("First one failed:", error);
}
}
// Practical use: Timeout pattern
function timeout(ms) {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('Timeout')), ms);
});
}
async function fetchWithTimeout() {
try {
const result = await Promise.race([
getUser(1),
timeout(5000) // 5 second timeout
]);
console.log("Got result:", result);
}
catch (error) {
console.log("Request timed out or failed:", error);
}
}
Error Handling: Mastering the Mistakes
Proper error handling is crucial in asynchronous code. Let's explore all the ways to handle errors effectively.
try/catch with async/await
This is the most common & readable way to handle errors with async/await:
async function fetchUserSafely(userId) {
try {
const user = await getUser(userId);
const posts = await getUserPosts(user.id);
return { user, posts };
}
catch (error) {
console.error("Error fetching user data:", error);
// You can return a default value
return { user: null, posts: [] };
}
}
finally Block - Always Execute
The finally block runs whether the Promise succeeds or fails:
async function fetchWithLoading(userId) {
let loading = true;
console.log("Loading started...");
try {
const user = await getUser(userId);
console.log("User:", user);
return user;
}
catch (error) {
console.error("Error:", error);
return null;
}
finally {
loading = false;
console.log("Loading finished!");
// This runs no matter what happens above...
}
}
Error Handling with Promise.all()
// If any Promise fails, the whole thing fails...
async function fetchAllOrNothing() {
try {
const [user1, user2, user3] = await Promise.all([
getUser(1),
getUser(2),
getUser(999) // This will fail...
]);
console.log("All users:", user1, user2, user3);
}
catch (error) {
console.log("At least one failed:", error);
// You only get the first error, others are lost...
}
}
// Better: Handle each individually...
async function fetchAllWithIndividualHandling() {
const [user1, user2, user3] = await Promise.all([
getUser(1).catch(e => ({ error: e })),
getUser(2).catch(e => ({ error: e })),
getUser(999).catch(e => ({ error: e }))
]);
// Now we can check each result...
if (user1.error) console.log("User 1 failed:", user1.error);
if (user2.error) console.log("User 2 failed:", user2.error);
if (user3.error) console.log("User 3 failed:", user3.error);
}
Custom Error Classes
Create specific error types for better error handling:
class NetworkError extends Error {
constructor(message) {
super(message);
this.name = 'NetworkError';
}
}
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
}
}
function getUserId(userId) {
return new Promise((resolve , reject) => {
if (!userId) {
reject(new ValidationError("User ID is required!"));
}
else if (userId < 0) {
reject(new ValidationError("User ID must be positive!"));
}
else {
const hasNetworkIssue = Math.random() * 0.3;
if (hasNetworkIssue) {
reject(new NetworkError("Failed to connect to server!"));
}
else {
resolve({ id: userId, name: "John" });
}
}
});
}
async function fetchUserWithCustomErrors(userId) {
try {
const user = await getUser(userId);
return user;
}
catch (error) {
if (error instanceof ValidationError) {
console.log("Validation failed:", error.message);
// Handle validation errors...
}
else if (error instanceof NetworkError) {
console.log("Network issue:", error.message);
// Retry logic could go here...
}
else {
console.log("Unknown error:", error);
}
}
}
Practical Examples: Real-World Scenarios
Let's put everything together with real-world examples.
Example 1: Data Fetching with Caching
const cache = {};
function fetchUserFromAPI(userId) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`Fetching user ${userId} from API...`);
resolve({
id: userId,
name: `User ${userId}`,
fetchedAt: new Date()
});
} , 2000);
});
}
async function getUser(userId , useCache = true) {
if (useCache && cache[userId]) {
console.log(`Using cached data for user ${userId}`);
return cache[userId];
}
try {
const user = await fetchUserFromAPI(userId);
cache[userId] = user;
return user;
}
catch (err) {
console.error("Failed to fetch user:", error);
throw err;
}
}
async function testCaching() {
console.log("First call: ");
const user1 = await getUser(1);
console.log(user1);
console.log("\nSecond call (should use cache):");
const user2 = await getUser(1);
console.log(user2);
console.log("\nThird call (forcing fresh data):");
const user3 = await getUser(1, false);
console.log(user3);
}
testCaching();
Example 2: Retry Logic With Exponential Backoff
function unreliableAPI() {
return new Promise((resolve , reject) => {
// 70% chances of failure...
if (Math.random() < 0.7) {
reject(new Error("API temporarily unavailable"));
}
else {
resolve({data: "Success!"});
}
});
}
async function retryWithBackoff(fn , maxRetries = 3 , initialDelay = 1000) {
let lastErr;
for (let i = 0; i < maxRetries; i++) {
try {
console.log(`Attempt \({i + 1} of \){maxRetries}`);
const res = await fn();
console.log("Success!");
return res;
}
catch (err) {
lastErr = err;
console.log(`Attempt ${i + 1} failed:`, err.message);
// Don't wait after the last attempt...
if (i < maxRetries - 1) {
// Exponential backoff: 1s, 2s, 4s, 8s...
const delay = initialDelay * Math.pow(2 , i);
console.log(`Waiting ${delay}ms before retry...`);
await new Promise(resolve => setTimeout(resolve , delay));
}
}
}
throw new Error(`Failed after \({maxRetries} attempts: \){lastErr.message}`);
}
async function testRetry() {
try {
const res = await retryWithBackoff(unreliableAPI , 5);
console.log("Final result:" , res);
}
catch (err) {
console.error("All retries failed:" , err.message);
}
}
testRetry();
Example 3: Race Condition with Timeout
function fetchData(url) {
return new Promise((resolve) => {
const delay = Math.random() * 4000 + 1000;
setTimeout(() => {
resolve({ url , data: "Some data" , delay });
} , delay);
});
}
function timeout(ms , timeoutMsg = "Operation timed out!") {
return new Promise((_ , reject) => {
setTimeout(() => reject(new Error(timeoutMsg)) , ms);
});
}
async function fetchWithTimeout(url , timeoutMs = 3000) {
try {
const res = await Promise.race([
fetchData(url),
timeout(timeoutMs , `Request to \({url} timed out after \){timeoutMs}ms!`)
]);
console.log(`Success! Took ${res.delay}ms`);
return res;
}
catch (err) {
console.error(err.message);
throw err;
}
}
async function testTimeout() {
console.log("Fetching with 3 second timeout...");
try {
await fetchWithTimeout("https://api.example.com/data");
} catch (error) {
console.log("First request failed or timed out");
}
try {
await fetchWithTimeout("https://api.example.com/data");
} catch (error) {
console.log("Second request failed or timed out");
}
}
testTimeout();
Best Practices & Key Takeaways
Do's ✅
Use async/await for readability - It's cleaner than Promise chains for most cases.
Always handle errors - Use try/catch or
.catch()Use Promise.all() for parallel operations - Don't await unnecessarily.
Return values from async functions - Make them chainable.
Use finally for cleanup - Release resources, hide loaders, etc.
Don'ts ❌
Don't forget to await - Your Promises won't execute as expected.
Don't ignore Promise rejections - Always handle errors.
Don't mix .then() and await unnecessarily - Pick one style and stick with it.
Don't await inside loops unless you need sequential execution.
Conclusion
We've now mastered Promise chaining, async/await syntax, comprehensive error handling & real-world patterns for asynchronous JavaScript. I hope the concepts & explanations are clear & that you can handle any async operation JavaScript throws your way :)

