Making asynchronous JavaScript actually make sense
If you've ever written JavaScript that needs to wait for something—like fetching data from an API, reading a file, or waiting for a timer—you've dealt with asynchronous code. This tutorial will take you from the messy world of callbacks to the elegant world of Promises and async/await.
Understanding the Problem: Why Callbacks Get Messy
Let's start with a real scenario. Imagine you're building an app that needs to:
- Fetch a user's profile
- Then fetch their posts
- Then fetch comments on those posts
The Callback Approach
fetchUser(userId, function(user) {
fetchPosts(user.id, function(posts) {
fetchComments(posts[0].id, function(comments) {
console.log(comments);
});
});
});
This code tells JavaScript: "Call fetchUser, and when it finishes, execute this function. Inside that function, call fetchPosts, and when that finishes, execute another function. Inside that function, call fetchComments..."
See the problem? Each level of indentation represents waiting for something to complete. This pattern is called "callback hell" or the "pyramid of doom."
Enter Promises: A Better Way to Handle Async Code
A Promise is a JavaScript object that represents a future value. Think of it as a container that will eventually hold either:
- A successful result, or
- An error explaining what went wrong
Creating Your First Promise
const myPromise = new Promise(function(resolve, reject) {
setTimeout(function() {
resolve("Success!");
}, 1000);
});
This code tells JavaScript: "Create a new Promise object. Give me two functions: resolve (for success) and reject (for failure). After 1000 milliseconds, call resolve with the string 'Success!'"
The Promise starts in a "pending" state. When resolve is called, it becomes "fulfilled." If reject is called, it becomes "rejected."
Using a Promise with .then()
myPromise.then(function(result) {
console.log(result); // "Success!"
});
This tells JavaScript: "When myPromise fulfills, execute this function with the result."
Handling Errors with .catch()
const failingPromise = new Promise(function(resolve, reject) {
setTimeout(function() {
reject(new Error("Something went wrong!"));
}, 1000);
});
failingPromise
.then(function(result) {
console.log(result); // This won't run
})
.catch(function(error) {
console.log(error.message); // "Something went wrong!"
});
This tells JavaScript: "Try to handle the successful result with .then(), but if the Promise rejects, jump to .catch() and handle the error there."
Promise Chaining: Solving Callback Hell
Here's where Promises shine. Let's rewrite our nested callback example:
fetchUser(userId)
.then(function(user) {
return fetchPosts(user.id);
})
.then(function(posts) {
return fetchComments(posts[0].id);
})
.then(function(comments) {
console.log(comments);
})
.catch(function(error) {
console.log("Error:", error);
});
This tells JavaScript: "Call fetchUser. When it completes, take its result and call fetchPosts. When that completes, take its result and call fetchComments. When that completes, log the comments. If any step fails, catch the error at the end."
Key rule: When you return a value from a .then() handler, it gets wrapped in a new Promise automatically. When you return a Promise from a .then() handler, the next .then() waits for that Promise to resolve.
A Practical Example: API Calls
function getUserData(username) {
return fetch(`https://api.github.com/users/${username}`)
.then(function(response) {
return response.json();
})
.then(function(userData) {
console.log("User:", userData.name);
return fetch(userData.repos_url);
})
.then(function(response) {
return response.json();
})
.then(function(repos) {
console.log("Repos:", repos.length);
})
.catch(function(error) {
console.log("Failed to fetch:", error);
});
}
getUserData("octocat");
This tells JavaScript: "Fetch the user data, convert the response to JSON, log the user's name, fetch their repositories using the URL from the user data, convert that response to JSON, log the number of repos, and catch any errors along the way."
Working with Multiple Promises
Promise.all() - Wait for Everything
const promise1 = fetch("https://api.example.com/data1");
const promise2 = fetch("https://api.example.com/data2");
const promise3 = fetch("https://api.example.com/data3");
Promise.all([promise1, promise2, promise3])
.then(function(responses) {
console.log("All fetches completed!");
return Promise.all(responses.map(r => r.json()));
})
.then(function(data) {
console.log("Data 1:", data[0]);
console.log("Data 2:", data[1]);
console.log("Data 3:", data[2]);
})
.catch(function(error) {
console.log("At least one fetch failed:", error);
});
This tells JavaScript: "Start all three fetch operations simultaneously. Wait until all three complete. When they do, give me an array of all the responses. If any of them fail, jump to the catch block immediately."
Promise.race() - First One Wins
const slowAPI = fetch("https://slow-api.com/data");
const timeout = new Promise(function(resolve, reject) {
setTimeout(function() {
reject(new Error("Request timed out"));
}, 5000);
});
Promise.race([slowAPI, timeout])
.then(function(response) {
console.log("Got response in time!");
})
.catch(function(error) {
console.log(error.message); // "Request timed out" if API is too slow
});
This tells JavaScript: "Start both promises—the API call and the timeout. Whichever one finishes first (resolves or rejects), use that result and ignore the other one."
Async/Await: The Cleanest Syntax Yet
async/await is syntactic sugar over Promises. It makes asynchronous code look and behave more like synchronous code.
The async Keyword
async function getUser() {
return "John Doe";
}
This tells JavaScript: "Create a function called getUser that always returns a Promise. Even though I'm returning a string, wrap it in a resolved Promise automatically."
getUser().then(function(name) {
console.log(name); // "John Doe"
});
The await Keyword
async function fetchUserData(userId) {
const response = await fetch(`https://api.example.com/users/${userId}`);
const userData = await response.json();
return userData;
}
This tells JavaScript: "Create an async function. Call fetch and pause this function's execution until the Promise resolves. Store the result in response. Then call response.json() and pause again until that Promise resolves. Store that result in userData and return it."
Important: await only works inside async functions. It literally pauses the function execution without blocking other JavaScript code.
Our Original Problem, Solved with Async/Await
Remember the callback hell? Here's the async/await version:
async function getCommentsForUser(userId) {
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);
console.log(comments);
}
This tells JavaScript: "Wait for the user to be fetched, store it. Wait for the posts to be fetched, store them. Wait for the comments to be fetched, store them. Then log the comments."
No nesting. No .then() chains. Just clean, readable code that executes line by line.
Error Handling with Try/Catch
async function safelyFetchData(url) {
try {
const response = await fetch(url);
const data = await response.json();
console.log("Data received:", data);
return data;
} catch (error) {
console.log("Something went wrong:", error.message);
return null;
}
}
This tells JavaScript: "Try to execute this code block. If any await statement's Promise rejects (or any other error occurs), immediately jump to the catch block and handle the error there."
Using Promise.all() with Async/Await
async function fetchMultipleUsers(userIds) {
const promises = userIds.map(function(id) {
return fetch(`https://api.example.com/users/${id}`);
});
const responses = await Promise.all(promises);
const users = await Promise.all(
responses.map(function(response) {
return response.json();
})
);
return users;
}
This tells JavaScript: "Create an array of fetch Promises, one for each user ID. Wait for all of them to complete. Then convert each response to JSON, and wait for all of those to complete. Return the array of user data."
Common Patterns and Best Practices
Pattern 1: Sequential vs. Parallel Execution
Sequential (slower):
async function getSequential() {
const user = await fetchUser(1); // Wait 1 second
const posts = await fetchPosts(2); // Wait 1 second
const comments = await fetchComments(3); // Wait 1 second
// Total: 3 seconds
}
This tells JavaScript: "Fetch the user and wait. When done, fetch posts and wait. When done, fetch comments and wait."
Parallel (faster):
async function getParallel() {
const [user, posts, comments] = await Promise.all([
fetchUser(1),
fetchPosts(2),
fetchComments(3)
]);
// Total: 1 second (they all run simultaneously)
}
This tells JavaScript: "Start all three fetches at the same time. Wait until all three complete. Then destructure the results into three variables."
Pattern 2: Converting Callback Functions to Promises
Let's say you have an old callback-based function:
function readFileCallback(filename, callback) {
// Simulating file reading
setTimeout(function() {
callback(null, "File contents here");
}, 1000);
}
You can "promisify" it:
function readFilePromise(filename) {
return new Promise(function(resolve, reject) {
readFileCallback(filename, function(error, data) {
if (error) {
reject(error);
} else {
resolve(data);
}
});
});
}
// Now you can use it with async/await
async function processFile() {
const data = await readFilePromise("data.txt");
console.log(data);
}
This tells JavaScript: "Create a Promise that wraps the callback function. When the callback is called with an error, reject the Promise. When it's called with data, resolve the Promise with that data."
Pattern 3: Error Handling for Specific Cases
async function fetchWithRetry(url, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
if (i === maxRetries - 1) {
throw error; // Last attempt failed, give up
}
console.log(`Attempt ${i + 1} failed, retrying...`);
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
}
}
}
This tells JavaScript: "Loop up to maxRetries times. Try to fetch the URL. If it fails and we haven't exceeded max retries, log a message, wait 1 second, and try again. If the last attempt fails, throw the error."
Quick Reference: Promises vs Async/Await
With Promises:
function getDataWithPromises() {
return fetchUser(1)
.then(function(user) {
return fetchPosts(user.id);
})
.then(function(posts) {
return fetchComments(posts[0].id);
})
.catch(function(error) {
console.log(error);
});
}
With Async/Await:
async function getDataWithAsync() {
try {
const user = await fetchUser(1);
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);
return comments;
} catch (error) {
console.log(error);
}
}
Both do exactly the same thing. Choose async/await for cleaner, more readable code.
Final Tips
Always handle errors: Use
.catch()ortry/catchto prevent unhandled Promise rejections.Don't forget to await: Forgetting
awaitmeans you get a Promise, not the value.
const data = fetch(url); // Promise object
const data = await fetch(url); // Actual response
Avoid mixing styles: Pick either Promises or async/await, don't mix unnecessarily.
Use Promise.all() for parallel operations: Don't await in a loop if operations can run simultaneously.
Remember that async functions always return Promises: Even if you return a plain value, it gets wrapped in a Promise.
Top comments (0)