Understanding Callback, Promise, and async/await in JavaScript for Better Async Programming
Whole Asynchronous programming runs based on the concept of having a single-threaded, meaning it executes one operation at a time. However, modern applications often require executing long-running tasks (like network requests or file operations) without freezing the user interface. Asynchronous programming allows programming languages to handle these tasks efficiently.
Let’s go through three key techniques for handling asynchronous tasks:
1. Callbacks
2. Promises
3. Async/Await
1. Callbacks
What is a Callback?
A callback is a function passed as an argument to another function and it is executed after the completion of an operation.
A callback is like a waiter or waitress at a restaurant. They come to take your order and interact with you until it's noted. Once they pass your order to the chef, they move on to other tables and do the same. When your order is ready and they’re available, they come back to serve you, then continue with their other tasks.
In this way, one waiter or waitress serves multiple tables asynchronously. Callback also does the same thing with one thread.
Synchronous Example:
function greet(name) {
console.log("Hello " + name);
}
function getName(callback) {
let name = "Alice";
callback(name); // Callback is called with the name
}
getName(greet);
In the above example, greet is passed as a callback function to getName and it gets executed after name is available.
Asynchronous Example: Using Callbacks for Async Operations
function fetchData(callback) {
setTimeout(() => {
console.log("Data fetched");
callback("Fetched data");
}, 2000);
}
fetchData((data) => {
console.log(data);
});
Here, fetchData simulates an async task using setTimeout and accepts a callback function that is executed once the data is fetched.
Problems with Callbacks
While callbacks are useful, they have some significant drawbacks:
- Callback Hell: When multiple asynchronous operations are required, callbacks can quickly lead to deeply nested code, known as "callback hell".
- Error Handling: Managing errors across multiple callbacks can be tricky and non-intuitive.
Example of Callback Hell:
Suppose you want to call an API, and once you receive the response, you need to call another API with that data. After that, you call a third API using the data from the second one, and finally, you want to display something with all the results. This creates a chain of dependent operations.
With callbacks, this can lead to deeply nested code, often called callback hell, as each API call depends on the completion of the previous one.
setTimeout(() => {
console.log("API 1 Response");
const dataFromAPI1 = "Data from API 1";
setTimeout(() => {
console.log("API 2 Response with", dataFromAPI1);
const dataFromAPI2 = "Data from API 2";
setTimeout(() => {
console.log("API 3 Response with", dataFromAPI2);
const dataFromAPI3 = "Data from API 3";
// Finally show the data
console.log("Final Data:", dataFromAPI3);
}, 1000); // Simulating API 3 call
}, 1000); // Simulating API 2 call
}, 1000); // Simulating API 1 call
Of course, this can be written better by dividing it into functions
function callAPI1(callback) {
setTimeout(() => {
console.log("API 1 Response");
callback("Data from API 1");
}, 1000);
}
function callAPI2(dataFromAPI1, callback) {
setTimeout(() => {
console.log("API 2 Response with", dataFromAPI1);
callback("Data from API 2");
}, 1000);
}
function callAPI3(dataFromAPI2, callback) {
setTimeout(() => {
console.log("API 3 Response with", dataFromAPI2);
callback("Data from API 3");
}, 1000);
}
// Callback Hell
callAPI1((data1) => {
callAPI2(data1, (data2) => {
callAPI3(data2, (data3) => {
console.log("Final Data:", data3);
});
});
});
Still, it’s confusing.
Let’s take another example which represents “Error Handling”.
Example of Error Handling:
One of the limitations of callbacks is the complexity of error handling, especially with asynchronous operations. You need to pass an error object to the callback to handle any failures gracefully.
function fetchDataWithErrorHandling(callback) {
setTimeout(() => {
const error = false; // Simulate error condition
if (error) {
callback("Error: Failed to fetch data", null);
} else {
callback(null, "Fetched data");
}
}, 2000);
}
fetchDataWithErrorHandling((err, data) => {
if (err) {
console.error(err);
} else {
console.log(data);
}
});
Solution: To mitigate these problems (callback hell & complicated error handling), Promises were introduced in JavaScript.
2. Promises
What is a Promise?
A Promise is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value. It acts as a placeholder for data that might not be available yet but will be at some point in the future.
The promise is like a shipment tracking number. It’s a promise that the item is on the way it will be delivered if everything ok if not it gets rejected.
Promises have three distinct states:
Pending: The initial state, neither fulfilled nor rejected.
Fulfilled: The operation was completed successfully and the Promise has a result.
Rejected: The operation failed, and the Promise has a reason (an error) for failure/rejection.
Promise Example
let examplePromise = new Promise((resolve, reject) => {
let success = true; // Simulating success or failure
if (success) {
resolve("The operation was successful");
} else {
reject("The operation failed");
}
});
resolve: A function called when the operation completes successfully. It moves the promise from the pending state to fulfilled.
reject: A function called when the operation fails. It moves the promise from the pending state to rejected.
Using Promises:
We use .then() and .catch() to consume a promise.
examplePromise
.then((result) => {
console.log(result); // Handle success
})
.catch((error) => {
console.error(error); // Handle error
});
The then() method is called when the Promise is resolved (fulfilled), and catch() is called when it's rejected.
Example: Async Operation Using Promise
const fetchData = new Promise((resolve, reject) => {
setTimeout(() => {
console.log("Fetching data...");
let success = true;
if (success) {
resolve("Data fetched successfully");
} else {
reject("Failed to fetch data");
}
}, 2000); // Simulating a 2-second delay
});
// Using the promise
fetchData
.then((data) => {
console.log(data); // Outputs: Data fetched successfully
})
.catch((error) => {
console.error(error);
});
Chaining Promises
One of the greatest strengths of promises is the ability to chain multiple asynchronous operations in sequence, avoiding deeply nested callbacks.
const step1 = new Promise((resolve, reject) => {
setTimeout(() => resolve("Step 1 complete"), 1000);
});
const step2 = new Promise((resolve, reject) => {
setTimeout(() => resolve("Step 2 complete"), 1000);
});
step1
.then((result1) => {
console.log(result1); // Output: Step 1 complete
return step2; // Returning another promise
})
.then((result2) => {
console.log(result2); // Output: Step 2 complete
});
.catch((error) => {
console.error("Error occurred:", error);
});
Here, both promises are chained together with .then() and if any error occurring in the chain is caught by .catch().
Promise Helper Methods: Promise.all(), Promise.race(), and More
Promises come with several helper methods to handle multiple asynchronous tasks simultaneously.
Promise.all() allows you to wait for multiple promises to complete. It returns a single promise that resolves when all the promises passed to it resolve or rejects if any of the promises reject.
const promise1 = Promise.resolve("Result 1");
const promise2 = Promise.resolve("Result 2");
Promise.all([promise1, promise2]).then((results) => {
console.log(results); // Output: ["Result 1", "Result 2"]
});
Promise.race() returns a promise that resolves or rejects as soon as one of the promises in the array settles (either resolved or rejected).
const promise1 = new Promise((resolve) => setTimeout(resolve, 500, "First"));
const promise2 = new Promise((resolve) => setTimeout(resolve, 1000, "Second"));
Promise.race([promise1, promise2]).then((result) => {
console.log(result); // Output: "First" (because it resolves first)
});
Advantages of Promises
- No more callback hell: With `.then()`, Promises flatten the callback chain, making it more readable.
- Centralized error handling: Errors are caught in `.catch()` and handled in one place.
- Better Control of Async Tasks: Methods like Promise.all() and Promise.race() provide powerful ways to manage multiple async operations concurrently.
Common Mistakes When Using Promises
Not Returning Promises: If you forget to return a promise inside .then(), it can break the chain.
Wrong Version: // This will not work as expected because no promise is returned fetchData().then((data) => { processData(data); }); Correct version: fetchData().then((data) => { return processData(data); // Must return the promise });
Not Handling Rejection: Always use .catch() to handle promise rejections. Failing to do so can result in unhandled errors in your code.
3. Async/Await
What is Async/Await?
async/await is syntactic sugar built on top of Promises. It makes asynchronous code look and behave more like synchronous code, resulting in more readable and maintainable code.
- async declares a function as asynchronous and always returns a Promise.
- await pauses the execution of the function until the Promise is resolved.
Example of Async/Await
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("Fetching data...");
resolve("Data fetched");
}, 2000);
});
}
async function getData() {
try {
const data = await fetchData();
console.log(data); // This line waits for fetchData to complete
} catch (error) {
console.error(error);
}
}
getData();
In this example:
- The getData function is declared async, meaning it contains asynchronous code.
- The await keyword pauses the execution until the Promise returned by fetchData() is resolved.
- try/catch handles any potential errors.
Sequential Async/Await Example
One of the main advantages of async/await is how it simplifies chaining asynchronous operations. Instead of using .then() repeatedly, you can simply await each step in a sequence, creating more readable and maintainable code.
Example:
Suppose you have three asynchronous operations that depend on each other:
async function performTasks() {
let result1 = await task1();
let result2 = await task2(result1);
let result3 = await task3(result2);
console.log("Final result:", result3);
}
performTasks();
Parallel Execution with Promise.all()
In some cases, you might want to execute asynchronous tasks in parallel rather than in sequence. You can achieve this with async/await by using Promise.all().
async function fetchAllData() {
let [data1, data2] = await Promise.all([fetchData1(), fetchData2()]);
console.log(data1, data2);
}
fetchAllData();
In this example, fetchData1() and fetchData2() are executed in parallel and await Promise.all() ensures that the function only continues once both promises have resolved.
Advantages of Async/Await
- Looks like synchronous code: It makes async operations easy to write and read.
- Error handling: Use of try/catch for error handling makes the code clean.
- No more chaining: The code flow is linear without chaining .then().
Common Mistakes When Using async/await
Forgetting to await a Promise
If you forget to use await for a promise, the function will continue execution without waiting for the promise to resolve.
async function fetchData() { let data = fetch("https://api.example.com/data"); // Missing await! console.log(data); // This logs a Promise, not the data }
Using await in Non-Async Functions
The await keyword can only be used inside functions marked with async. Trying to use it in a non-async function will throw a syntax error.
function someFunction() { let data = await fetch("https://api.example.com/data"); // Error! }
To fix this, make sure the function is declared as async
Sequential vs. Parallel Execution
If you're awaiting promises inside a loop, the promises will execute sequentially, which might slow down your program if the tasks can run in parallel.
// This runs sequentially for (let i = 0; i < 5; i++) { await someAsyncTask(); }
For parallel execution, use Promise.all() to await all tasks simultaneously
Did I miss anything? If yes, Please help me make it better by sharing your thoughts in the comments.
👋 Let’s be friends! Follow me on X and connect with me on LinkedIn. Don’t forget to Subscribe as well.
My X handle: https://x.com/ashok_tankala
My LinkedIn Profile: https://www.linkedin.com/in/ashoktankala/