Asynchronous JavaScript: promises and async await
JavaScript is a single-threaded language, which means it can only perform one operation at a time on its main execution thread. However, there are often need to perform multiple operations simultaneously, in that situation it shouldn’t block the main thread of execution while it is running. To manage these simultaneous operations efficiently, JavaScript provides mechanisms for asynchronous programming.
JavaScript supports three ways for implementing asynchronous programming - callbacks, promises, and async/await.
Callbacks
A callback is a function passed into another function as an argument and is executed after some task has been completed. Callbacks have been the traditional way of handling asynchronous operations in JavaScript.
function fetchData(callback) {
setTimeout(() => {
const data = "Data fetched successfully!";
callback(data);
}, 1000);
}
function displayData(data) {
console.log(data);
}
console.log(1); // First log, executed immediately
fetchData(displayData);
console.log(2); // Second log, executed before the async operation completes
Output
1
2
Data fetched successfully!
In the above example, fetchData
simulates fetching data from a server with a delay using setTimeout
. Once the data is fetched, the displayData
function is called.
Advantages of Callbacks
- Simple: Callbacks are straightforward and simple way of handling asynchronous operations.
- Widely Supported: Callbacks is core part of JavaScript from start, making it compatible with nearly all JavaScript environments, including older browsers and runtime systems.
Shortcomings of Callbacks
- Error Handling: Handling errors with callbacks can be tricky, especially when there are many callbacks nested inside each other. It makes the code hard to read and manage.
- Callback Hell: Multiple nested callbacks can lead to deeply nested and hard-to-read code, referred to as "callback hell.”
function fetchUserData(userId, callback) {
setTimeout(() => {
callback(null, { id: userId, name: "John Doe" });
}, 1000);
}
function fetchPosts(userId, callback) {
setTimeout(() => {
callback(null, [{ id: 1, userId: userId, title: "My First Post" }]);
}, 1000);
}
function fetchComments(postId, callback) {
setTimeout(() => {
callback(null, [{ id: 1, postId: postId, content: "Great post!" }]);
}, 1000);
}
fetchUserData(1, (err, user) => {
if (err) {
console.error(err);
return;
}
fetchPosts(user.id, (err, posts) => {
if (err) {
console.error(err);
return;
}
fetchComments(posts[0].id, (err, comments) => {
if (err) {
console.error(err);
return;
}
console.log("User:", user);
console.log("Posts:", posts);
console.log("Comments:", comments);
});
});
});
Like the above example the nested structure makes the code hard to read and maintain. This is why modern JavaScript prefers Promises and async/await for handling asynchronous operations.
Promises
A Promise is an object that notifies when a provided asynchronous operation has been completed, either as successful or as unsuccessful one. It can be in one of three states: pending (initial state), fulfilled (operation completed successfully), or rejected (operation failed).
Promise help avoid issues like "callback hell" by providing a clearer and more organized syntax. It provides a structured way to handle asynchronous code, it can be chained using .then()
for success cases and .catch()
for error handling.
function fetchData(success) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (success) {
resolve({ data: "Data fetched successfully!" });
} else {
reject(new Error("Data fetch failed!"));
}
}, 1000);
});
}
console.log(1);
fetchData(false)
.then((data) => {
console.log(data.data);
})
.catch((error) => {
console.error(error.message);
});
console.log(2);
Output
1
2
Data fetch failed!
Advantages of Promises
- Better Error Handling: Promises provide a standardized way to handle errors through the
.catch()
method, making error management more structured and predictable. - Avoiding Callback Hell: Promises provide a more readable and maintainable way to handle asynchronous operations, avoiding the deeply nested structure like callbacks.
- Improved Readability: Promises are generally more readable than callbacks.
Shortcomings of Promises
- Nested Chaining: If a number of promises are made in succession or are interdependent then it becomes difficult to follow and become harder to trace.
- Debugging Issues: Errors in promises can be difficult to debug, especially when they occur but are not properly logged or handled, making them easy to overlook.
function fetchUser() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id: 1, name: "John Doe" });
}, 1000);
});
}
function fetchUserPosts(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve([{ id: 1, userId: userId, title: "Post 1" }]);
}, 1000);
});
}
function fetchPostComments(postId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
// Simulating an error
reject(new Error("Failed to fetch comments"));
}, 1000);
});
}
fetchUser()
.then(user => {
return fetchUserPosts(user.id);
})
.then(posts => {
return fetchPostComments(posts[0].id);
})
.then(comments => {
console.log("Comments:", comments);
})
.catch(error => {
console.error("Error:", error.message);
});
In the above example,the rejection of the promise in fetchPostComments
is properly handled by the .catch
block, which logs the error message. However, if the .catch
block were not present or if the error was not properly handled, the error would be ignored without any notification, making it difficult to debug and trace the issue.
Async/Await
Async/await is built on top of Promise to make it better. It is more concise and intuitive way for writing asynchronous program, it enhance code readability and make it easier to manage complex asynchronous flows.
The async
function allows to write promise-based code as if it is synchronous. This ensures that the execution thread is not blocked. Async functions always return a promise. If a value is returned that is not a promise, JavaScript automatically wraps it in a resolved promise. The await
keyword is used to wait for a promise to resolve. It can only be used within an async functions.
async function fetchData(success) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (success) {
resolve({ data: "Data fetched successfully!" });
} else {
reject(new Error("Data fetch failed!"));
}
}, 1000);
});
}
async function getData(success) {
try {
const data = await fetchData(success);
console.log(data.data);
} catch (error) {
console.error(error.message);
}
}
console.log(1);
getData(false);
console.log(2);
Output
1
2
Data fetch failed!
Advantages of async/await
- Synchronous-Looking Code: async/await allow asynchronous code to be written in a synchronous style, making it easier to read and understand.
- Simplified Error Handling: Using traditional way of try/catch blocks with async/await simplifies error handling.
- Easier to Debug: async/await code is easier to debug as it is more intuitive.
Shortcoming of async/await
- Browser Support: While modern browsers support async/await, older browsers may require transpilation or polyfills.
- Memory Usage: Async functions create additional promise objects and maintain the execution context, which can lead to slightly higher memory usage compared to raw promises.
async function fetchUser() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id: 1, name: "John Doe" });
}, 1000);
});
}
async function fetchUserPosts(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve([{ id: 1, userId: userId, title: "Post 1" }]);
}, 1000);
});
}
async function fetchPostComments(postId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
// Simulating an error
reject(new Error("Failed to fetch comments"));
}, 1000);
});
}
async function getHomePageData() {
try {
const user = await fetchUser();
console.log("User:", user);
const posts = await fetchUserPosts(user.id);
console.log("Posts:", posts);
const comments = await fetchPostComments(posts[0].id);
console.log("Comments:", comments);
} catch (error) {
console.error("Error:", error.message);
}
}
getHomePageData();
Conclusion
Asynchronous programming is needed in JavaScript because of its single-threaded nature. It enables non-blocking operations, ensuring the main thread remains responsive. The primary constructs for implementing asynchronous programming in JavaScript are callbacks, promises, and async/await.
Callbacks were the traditional way of handling asynchronous operations, but it often lead to callback hell and difficulties in error handling. Promises introduced a more structured and readable approach, offering better error handling and avoiding deeply nested callbacks. Async/await built on top of promises, providing a more intuitive and synchronous-looking way to write asynchronous code, simplifying error handling and improving readability.
Promises and async/await can be used together effectively. As async/await is built on promises, there will be scenarios where we might want to use both. For instance, if we want to use Promise.all
for parallel execution of multiple asynchronous tasks and then use async/await for handling their results.
async function fetchMultipleAPIs(urls) {
try {
const promises = urls.map(url => fetch(url).then(response => response.json()));
const results = await Promise.all(promises);
console.log("Data from all APIs:", results);
} catch (error) {
console.error("Error fetching data from APIs:", error);
}
}
const apis = [
"https://jsonplaceholder.typicode.com/posts/23",
"https://jsonplaceholder.typicode.com/comments/23",
"https://jsonplaceholder.typicode.com/todos/23"
];
fetchMultipleAPIs(apis);
The above example demonstrates how to use Promise.all
with async/await for executing multiple asynchronous operations in parallel and handling their results together.
By understanding and combining these effectively, we can write cleaner, more efficient, and more maintainable asynchronous JavaScript code.