Promises and Async Function in JavaScript
Before diving into the concepts, let's first understand how JavaScript executes code.
JavaScript code Execution :
JavaScript executes code using a single-threaded, event-driven model, which means it processes one operation at a time in a specific order. Here's a brief explanation of how JavaScript executes code:
Execution Context:
JavaScript code runs inside an execution context, which is an environment where the code is evaluated and executed. There are two main types of execution contexts:
Global Execution Context: This is the default context where your code starts executing.
Function Execution Context: Created whenever a function is invoked. Each function has its own execution context.
CallStack:
JavaScript uses a call stack to manage execution contexts. The call stack follows the Last In, First Out (LIFO) principle:
When a function is called, its execution context is pushed onto the stack.
When a function returns, its execution context is popped off the stack.
Example:
function greet() {
console.log('Hello, World!');
}
function sayGoodbye() {
console.log('Goodbye!');
}
greet();
sayGoodbye();
//output:
// Hello, World!
// Goodbye!
Explanation:
The global execution context is pushed onto the call stack.
greet()
is called, and its execution context is pushed onto the stack.console.log('Hello, World!')
is executed and thengreet()
is popped off the stack.sayGoodbye()
is called, and its execution context is pushed onto the stack.console.log('Goodbye!')
is executed and thensayGoodbye()
is popped off the stack.
Event Loop
JavaScript can perform asynchronous operations (e.g., setTimeout
, network requests) without blocking the main thread. The event loop enables this non-blocking behavior by managing asynchronous code execution.
Call Stack: Executes synchronous code.
Web APIs: Handle asynchronous operations (e.g.,
setTimeout
,fetch
).Task Queue (Callback Queue): Holds callbacks from asynchronous operations.
Event Loop: Continuously checks if the call stack is empty and pushes tasks from the task queue to the call stack for execution.
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 1000);
console.log('End');
//output:
//Start
//End
//Timeout
console.log('Start')
is executed and logged.setTimeout
is called, and its callback is sent to the Web APIs.console.log('End')
is executed and logged.After 1 second, the
setTimeout
callback is pushed to the task queue.The event loop pushes the callback to the call stack once it's empty.
console.log('Timeout')
is executed and logged.
Difference between Sync & Async
In programming, the terms "synchronous" and "asynchronous" describe different ways of executing tasks or operations. Understanding the difference between them is crucial for managing how your code behaves, especially in a single-threaded environment like JavaScript.
Synchronous :
Synchronous operations are executed sequentially. Each operation must complete before the next one starts. If a task is time-consuming, it will block the subsequent tasks until it finishes.
Characteristics:
Blocking: One task must finish before the next one begins.
Predictable Order: The sequence of execution is straightforward and easy to follow.
Simple: Easier to write and debug due to linear flow.
Example:
function syncTask() {
for (let i = 0; i < 5; i++) {
console.log(i);
}
}
console.log('Start');
syncTask();
console.log('End');
//output:
//Start
//0
//1
//2
//3
//4
//End
Asynchronous:
Asynchronous operations allow tasks to be executed without blocking the subsequent tasks. This means that a task can start and then pass control back to the main thread, allowing other tasks to run in the meantime. Once the async task is complete, it notifies the main thread to handle the result.
Characteristics:
Non-blocking: Allows other operations to continue while waiting for the async task to complete.
Unpredictable Order: The sequence of execution depends on when async tasks complete.
Efficient: Can improve performance by not waiting for tasks like network requests or file operations.
Example:
function asyncTask() {
setTimeout(() => {
console.log('Async Task Complete');
}, 2000);
}
console.log('Start');
asyncTask();
console.log('End');
//output:
//Start
//End
//Async Task Complete
Why are promises better than callbacks in handling async operations ?
Promises are better than callbacks because they provide a more robust and readable way to handle asynchronous operations, avoiding the pitfalls of callback-based code. Callbacks often lead to "callback hell" or "pyramid of doom," where nested callbacks become hard to read and maintain. This deeply nested structure complicates error handling and debugging. Promises, on the other hand, offer a cleaner, more linear syntax through .then()
, .catch()
, and .finally()
methods, allowing better chaining of asynchronous operations and more consistent error handling. This makes the code easier to follow and reduces the risk of mistakes, enhancing overall code quality and maintainability.
//function1
function fetchUser(userId, callback) {
setTimeout(() => {
console.log('Fetched user');
//callback1
callback(null, { userId });
}, 1000);
}
//function2
function fetchOrders(userId, callback) {
setTimeout(() => {
console.log('Fetched orders for user', userId);
//callback2
callback(null, [{ orderId: 1 }, { orderId: 2 }]);
}, 1000);
}
//function3
function fetchOrderDetails(orderId, callback) {
setTimeout(() => {
console.log('Fetched details for order', orderId);
//callback3
callback(null, { orderId, details: 'Order details' });
}, 1000);
}
fetchUser(1, (err, user) => {
if (err) {
console.error(err);
return;
}
//nesting function2 in callback1
fetchOrders(user.userId, (err, orders) => {
if (err) {
console.error(err);
return;
}
orders.forEach(order => {
fetchOrderDetails(order.orderId, (err, details) => {
//nesting function3 in callback2
if (err) {
console.error(err);
return;
}
console.log(details);
});
});
});
});
Explanation:
The code becomes nested and harder to read with each additional asynchronous operation.
Error handling is repetitive and scattered throughout the nested callbacks.
What is a Promise ?
A promise in JavaScript is an object representing the eventual completion or failure of an asynchronous operation. It allows you to write asynchronous code in a more synchronous and manageable fashion. Promises provide a cleaner and more powerful way to handle asynchronous operations compared to traditional callbacks.
How to Create a New Promise:
A new promise is created using the Promise
constructor, which takes a single argument: a function called the executor. The executor function takes two parameters, resolve
and reject
, which are used to determine the outcome of the promise.
Example:
const myPromise = new Promise((resolve, reject) => {
const success = true; // Simulate an operation result
if (success) {
resolve('Operation was successful!');
} else {
reject('Operation failed.');
}
});
Different States of a Promise:
Pending: The initial state, neither fulfilled nor rejected.
Fulfilled: The operation completed successfully, and the promise has a value.
Rejected: The operation failed, and the promise has a reason for failure (an error).
How to Consume an Existing Promise :
To consume a promise, you use the .then()
, .catch()
, and optionally .finally()
methods.
.then()
: Called when the promise is fulfilled. It takes two optional arguments: a callback for the resolved value and a callback for the rejection reason..catch()
: Called when the promise is rejected. It takes a single argument, a callback for the rejection reason..finally()
: Called when the promise is settled (either fulfilled or rejected). It takes a single argument, a callback that is executed regardless of the promise's outcome.
const myPromise = new Promise((resolve, reject) => {
const success = true; // Simulate an operation result
setTimeout(() => {
if (success) {
resolve('Operation was successful!');
} else {
reject('Operation failed.');
}
}, 1000);
});
myPromise
.then((message) => {
console.log(message); // Output: Operation was successful!
})
.catch((error) => {
console.error(error);
})
.finally(() => {
console.log('Promise settled'); // Always executed
});
Consuming Multiple Promises by Chaining :
Promise chaining allows you to perform multiple asynchronous operations in sequence. Each .then()
returns a new promise, which can be chained with additional .then()
, .catch()
, and .finally()
methods.
Example of Promise Chaining
Let's try to implement promise in the above callback hell example: fetching user data, fetching user orders, and then fetching details for each order. Here's how you can chain these promises:
function fetchUser(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('Fetched user');
resolve({ userId, name: 'John Doe' });
}, 1000);
});
}
function fetchOrders(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`Fetched orders for user ${userId}`);
resolve([{ orderId: 1 }, { orderId: 2 }]);
}, 1000);
});
}
function fetchOrderDetails(orderId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`Fetched details for order ${orderId}`);
resolve({ orderId, details: 'Order details' });
}, 1000);
});
}
// Chaining promises
fetchUser(1)
.then(user => {
console.log('User:', user);
return fetchOrders(user.userId);
})
.then(orders => {
console.log('Orders:', orders);
// Create an array of promises to fetch details for each order
const orderDetailsPromises = orders.map(order => fetchOrderDetails(order.orderId));
// Return a promise that resolves when all order details are fetched
return Promise.all(orderDetailsPromises);
})
.then(orderDetails => {
console.log('Order Details:', orderDetails);
})
.catch(error => {
console.error('Error:', error);
})
.finally(() => {
console.log('All operations completed');
});
Explanation
fetchUser
: Returns a promise that resolves with user data after 1 second.fetchOrders
: Returns a promise that resolves with orders for the given user after 1 second.fetchOrderDetails
: Returns a promise that resolves with details for a given order after 1 second.
promise based functions :
Promise.resolve:
Promise.resolve
creates a promise that is resolved with a given value. It can be used to convert a value to a promise or to return a resolved promise for a given value.
const resolvedPromise = Promise.resolve('Resolved value');
resolvedPromise.then(value => {
console.log(value); // Output: Resolved value
});
Promise.reject:
Promise.reject
creates a promise that is rejected with a given reason. It is used to return a rejected promise with a specified error or reason.
const rejectedPromise = Promise.reject('Error occurred');
rejectedPromise.catch(error => {
console.error(error); // Output: Error occurred
});
Promise.all:
Promise.all
takes an iterable (like an array) of promises and returns a single promise that resolves when all of the input promises have resolved, or rejects if any of the input promises reject.
const promise1 = Promise.resolve('First promise');
const promise2 = Promise.resolve('Second promise');
Promise.all([promise1, promise2]).then(values => {
console.log(values); // Output: ['First promise', 'Second promise']
}).catch(err=>{
console.log(err)}; //output : err when atleast one of the promises is rejected
Promise.allSettled :
Promise.allSettled
takes an iterable of promises and returns a promise that resolves when all of the input promises have settled (either resolved or rejected). The returned promise resolves with an array of objects that each describe the outcome of each promise.
const promise1 = Promise.resolve('First promise');
const promise2 = Promise.reject('Second promise failed');
Promise.allSettled([promise1, promise2]).then(results => {
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Promise ${index + 1} fulfilled:`, result.value);
} else {
console.log(`Promise ${index + 1} rejected:`, result.reason);
}
});
});
Promise.any :
Promise.any
takes an iterable of promises and returns a single promise that resolves as soon as any of the input promises resolves. If none of the promises resolve (if all reject), it rejects with an AggregateError.
const promise1 = Promise.reject('First promise failed');
const promise2 = Promise.resolve('Second promise');
Promise.any([promise1, promise2]).then(value => {
console.log(value); // Output: Second promise
}).catch(error => {
console.error(error.errors);
});
Promise.race :
Promise.race
takes an iterable of promises and returns a promise that resolves or rejects as soon as one of the input promises resolves or rejects.
const promise1 = new Promise((resolve, reject) => {
setTimeout(() => resolve('First promise'), 500);
});
const promise2 = new Promise((resolve, reject) => {
setTimeout(() => resolve('Second promise'), 100);
});
Promise.race([promise1, promise2]).then(value => {
console.log(value); // Output: Second promise
});
Promisifying an Asynchronous Callback-based Function
Promisifying is the process of converting a function that uses callbacks into a function that returns a promise. This allows you to use the modern async/await syntax or chaining with .then()
, .catch()
, and .finally()
, making your code cleaner and more manageable.
Let's look at how to promisify the setTimeout
function and the fs.readFile
function as examples.
setTimeout example :
function delay() {
return new Promise((resolve) => {
setTimeout(()=>{
resolve("resolved")
}, 1000);
});
}
// Using the promisified setTimeout
delay().then(() => {
console.log('Executed after 1 second');
});
fs.readfile example :
const fs = require('fs');
// Custom promisify function for fs.readFile
function readFileAsync(path, encoding) {
return new Promise((resolve, reject) => {
fs.readFile(path, encoding, (error, data) => {
if (error) {
reject(error);
} else {
resolve(data);
}
});
});
}
// Using the custom promisified readFile
readFileAsync('example.txt', 'utf8')
.then((data) => {
console.log('File content:', data);
})
.catch((error) => {
console.error('Error reading file:', error);
});
Conclusion :
Understanding and effectively using asynchronous programming is crucial for writing modern JavaScript applications. Promises provide a robust and cleaner way to handle asynchronous operations compared to traditional callbacks, eliminating issues like callback hell and improving code readability. Here's a brief recap of the key concepts covered:
JavaScript Execution: JavaScript is a single-threaded, non-blocking, asynchronous, concurrent language, relying on the event loop to handle asynchronous operations.
Synchronous vs Asynchronous: Synchronous operations block the execution flow until completion, while asynchronous operations allow other tasks to run concurrently.
Promises: A promise is an object representing the eventual completion or failure of an asynchronous operation, providing methods like
.then()
,.catch()
, and.finally()
for handling results and errors.Promise Methods:
Promise.resolve
: Quickly creates a resolved promise.Promise.reject
: Quickly creates a rejected promise.Promise.all
: Waits for all promises to resolve or any to reject.Promise.allSettled
: Waits for all promises to settle, regardless of resolve/reject.Promise.any
: Resolves as soon as any promise resolves, rejects if all reject.Promise.race
: Resolves or rejects as soon as the first promise resolves or rejects.
Promisifying Callbacks: Converting callback-based functions to return promises, making asynchronous code cleaner and easier to work with.
Consuming Multiple Promises: Using chaining and methods like
Promise.all
andPromise.race
to handle multiple asynchronous operations efficiently.
By leveraging these concepts and techniques, you can write asynchronous JavaScript code that is more manageable, readable, and maintainable. Promises, in particular, offer a powerful way to handle complex asynchronous workflows, making your development process smoother and more efficient.
Subscribe to my newsletter
Read articles from Rishi Kumar directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by