Master Asynchronous Programming in Node.js: Part 2 — Promises.

techiydude
9 min readSep 1, 2024

--

Promises are essential in the Node.js for managing asynchronous actions, which are essential for creating code that is effective and doesn’t block. Promises give you a clear and predictable code structure to handle operations like reading a file, obtaining data from an API, and doing intricate calculations. This post will explore promises, including definitions, functions, advanced ideas, and Node.js best practices for mastering their use.

What Are Promises in Node.js?

A promise in Node.js is an object that represents the eventual completion or failure of an asynchronous operation. It provides a way to attach callbacks to handle the result of the operation, enabling cleaner, more manageable code than traditional callback functions.

The Importance of Asynchronous Programming

Asynchronous programming is a paradigm that allows multiple tasks to run concurrently without blocking the main execution thread. In Node.js, this is particularly important because it operates on a single-threaded event loop, meaning that time-consuming operations must be handled asynchronously to maintain performance and responsiveness.

Anatomy of a Promise.

Promise has three primary states.

Pending: It is an initial state of promise, where it is neither fulfilled nor rejected.

Fulfilled: It is the state where the asynchronous operation has been completed successfully.

Rejected: It is the state where the asynchronous operation has been failed, usually with an error.

Promises have some important methods.

.then(): It handles the fulfillment of the Promise

.catch(): It handles the rejection of the Promise.

const myPromise = new Promise((resolve, reject) => {
const success = true;

if (success) {
resolve('The operation was successful!');
} else {
reject('The operation failed.');
}
});

myPromise
.then(result => {
console.log('Success:', result);
})
.catch(error => {
console.error('Error:', error);
});

Breakdown the above Example.

Creating a Promise: new Promise((resolve, reject) => { ... })

  • A promise is created using the Promise constructor.
  • The constructor takes a function as an argument. This function has two parameters: resolve and reject.
  • When an operation goes well, you call the resolve function; if it goes wrong, you call the reject function.

Inside the Promise:

  • Inside the promise function, you define the asynchronous operation or any logic that might succeed or fail.
  • In this example, we have a simple variable success that determines the outcome. It's set to true, meaning the operation is successful.

Handling Success:

  • if success is true, we will call resolve('The operation was successful!').
  • This resolves the promise and passes the string 'The operation was successful!' as a result.
  • The promise is now in a fulfilled state.

Handling Failure:

  • If success was false, we would call reject('The operation failed.').
  • This rejects the promise and passes the string 'The operation failed.' as the error.
  • The promise would then be in a rejected state.

Chaining with .then():

  • After creating the promise, we can handle the result using .then().
  • .then(result => { ... }) runs if the promise was resolved.
  • In our example, result would be 'The operation was successful!'.
  • We log this result to the console using console.log('Success:', result);.

Handling Errors with .catch():

  • If the promise was rejected, the .catch() block runs.
  • In our example, error would be 'The operation failed.'.
  • We log this error message to the console using console.error('Error:', error);.

How Promises Work: A Flow Chart

Flow chart of Promises

The flowchart you’ve provided illustrates the lifecycle and flow of a JavaScript Promise, including how it transitions between states and how the .then() and .catch() handlers are executed. Let’s break it down step by step:

1. Promise Created

  • Start Point: The flow begins when a Promise object is created using the new Promise() constructor.
  • Executor Function: When the Promise is instantiated, it immediately calls an executor function that typically starts an asynchronous operation (like fetching data or reading a file).

2. Async Operation

  • Pending State: The Promise is in a pending state during the asynchronous operation. It hasn’t been fulfilled or rejected yet.

3. Resolve or Reject

  • Once the asynchronous operation completes:
  • If successful, the Promise is resolved with a value, transitioning to the fulfilled state.
  • If an error occurs, the Promise is rejected, transitioning to the rejected state.

4. Fulfilled or Rejected

  • Fulfilled: If the Promise is resolved, it moves to the fulfilled state.
  • Rejected: If the Promise is rejected, it moves to the rejected state.

5. Microtask Queue

  • Regardless of whether the Promise is fulfilled or rejected, the associated .then() or .catch() handlers are placed in the Microtask Queue.
  • This queue is processed by the Event Loop once the current call stack is clear.

6. Event Loop

  • The Event Loop picks up tasks from the Microtask Queue and begins to execute them. This ensures that the .then() or .catch() handlers run after the current synchronous code finishes executing.

7. Then/Catch Handlers Execution

  • Handlers: Depending on the state of the Promise:
  • If it’s fulfilled, the .then() handler is called.
  • If it’s rejected, the .catch() handler is called.

8. Return Value

  • The return value from the .then() or .catch() handler can be:
  • Another Promise: If the handler returns a Promise, the chain continues with this new Promise.
  • Non-Promise Value: If the handler returns a non-Promise value, this value is wrapped in a new Promise, and the chain continues.

9. New Promise or Wrap in Promise

  • Depending on what is returned from the handlers:
  • New Promise: If a Promise is returned, it continues the chain with this new Promise.
  • Wrap in Promise: If a non-Promise value is returned, it’s wrapped in a Promise so the chain can continue.

10. All Handlers Executed / Promise Chain Complete

  • This signifies the end of the Promise chain once all .then() and .catch() handlers have been executed and there are no more handlers left to call.

Basic Promise Structure

A Promise is created using the Promise constructor, which takes a function as an argument. This function receives two parameters, resolve and reject, which are functions that determine the state of the promise:

let promise = new Promise((resolve, reject) => {
// asynchronous operation
if (/* success */) {
resolve("Success!");
} else {
reject("Failure!");
}
});

Executing Promises: .then(), .catch(), and .finally()

Once a promise is created, you can handle its result using the .then(), .catch(), and .finally() methods:

  • .then(): Executes a function after the promise is fulfilled.
  • .catch(): Handles errors if the promise is rejected.
  • .finally(): Executes a function regardless of the promise's outcome.
promise
.then(result => {
console.log(result); // Success!
})
.catch(error => {
console.error(error); // Failure!
})
.finally(() => {
console.log("Operation complete.");
});

Chaining Promises

Promises can be chained to execute multiple asynchronous operations sequentially. The .then() method returns a new promise, allowing chaining:

Let’s say I am fetching user data from an API, then using that data to fetch the user’s posts, and finally logging the results.

// Simulate fetching user data
function getUser(userId) {
return new Promise((resolve, reject) => {
console.log('Fetching user...');
setTimeout(() => {
if (userId) {
resolve({ id: userId, name: 'John Doe' });
} else {
reject('User ID not provided');
}
}, 1000);
});
}

// Simulate fetching posts of a user
function getPosts(userId) {
return new Promise((resolve, reject) => {
console.log('Fetching posts...');
setTimeout(() => {
if (userId) {
resolve([
{ postId: 1, content: 'Post 1 content' },
{ postId: 2, content: 'Post 2 content' }
]);
} else {
reject('No posts found for this user');
}
}, 1000);
});
}

// Using Promise chaining
getUser(1)
.then(user => {
console.log('User data:', user);
return getPosts(user.id);
})
.then(posts => {
console.log('Posts data:', posts);
})
.catch(error => {
console.error('Error:', error);
});

Explanation:

  1. getUser(userId):
  • This function returns a Promise that simulates an asynchronous operation (like fetching data from an API).
  • If a valid userId is provided, the Promise is resolved with the user data after 1 second.
  • If no userId is provided, the Promise is rejected with an error message.

2. getPosts(userId):

  • This function also returns a Promise that simulates fetching posts for a user.
  • If the userId is valid, it resolves with an array of posts after 1 second.
  • If something goes wrong, it is rejected with an error message.

3. Promise Chaining:

  • I am calling getUser(1), which returns a Promise.
  • In the .then() method, we receive the resolved user data and pass the user.id to getPosts(user.id).
  • getPosts(user.id) returns another Promise, and in the next .then(), we receive the posts data.
  • If any Promise in the chain fails (is rejected), the .catch() the method is called, and the error is logged.

Output

Fetching user...
User data: { id: 1, name: 'John Doe' }
Fetching posts...
Posts data: [ { postId: 1, content: 'Post 1 content' }, { postId: 2, content: 'Post 2 content' } ]

Key Points:

  • Chaining: Each .then() returns a new Promise, allowing you to chain subsequent operations.
  • Error Handling: The .catch() at the end catches any error that might occur in any of the previous promises in the chain.
  • Flow: The operations are executed sequentially, ensuring that getPosts() is only called after getUser() has successfully resolved.

Advanced Concepts in Promises

  • Promise.all(iterable): Executes all promises in parallel and waits for all to fulfill. If any promise is rejected, it returns the rejected value.
Promise.all([promise1, promise2, promise3])
.then(results => {
console.log(results); // Array of results if all succeed
})
.catch(error => {
console.error(error); // First rejection reason
});
  • Promise.race(iterable): Returns the result of the first fulfilled or rejected promise.
Promise.race([promise1, promise2])
.then(result => {
console.log(result); // Result of the first settled promise
})
.catch(error => {
console.error(error); // Error of the first settled promise
});

Promise.allSettled(iterable): Waits for all promises to settle, regardless of whether they fulfill or reject, and returns an array of the results.

Promise.allSettled([promise1, promise2])
.then(results => {
results.forEach(result => console.log(result.status));
});

Promise.any(iterable): Resolves as soon as any promise in the iterable is fulfilled, ignoring rejections.

Promise.any([promise1, promise2])
.then(result => {
console.log(result); // First successful result
})
.catch(errors => {
console.error(errors); // All promises were rejected
});

Error Handling in Promises

Error handling is critical when working with Promises. Unhandled rejections can cause issues in your application. Use .catch() to handle errors:

promise
.then(result => {
// process result
})
.catch(error => {
console.error("Caught an error:", error);
});

Cancelling Promises

Why Cancelling Promises Is Challenging?

One of the limitations of promises is that they cannot be canceled once initiated. This can be problematic in scenarios where you no longer need the result of an asynchronous operation.

Techniques to Cancel Promises?

  • Custom Cancellation Implementation:

We can create a custom cancellable promise by introducing an external control mechanism, such as a flag:

function cancellablePromise(promise) {
let isCancelled = false;

const wrappedPromise = new Promise((resolve, reject) => {
promise
.then(result => (isCancelled ? reject({ isCancelled: true }) : resolve(result)))
.catch(error => reject(error));
});

return {
promise: wrappedPromise,
cancel() {
isCancelled = true;
}
};
}

// Usage
const { promise, cancel } = cancellablePromise(new Promise((resolve) => setTimeout(resolve, 1000)));

promise
.then(result => console.log("Promise resolved:", result))
.catch(error => {
if (error.isCancelled) {
console.log("Promise was cancelled");
} else {
console.error("Promise rejected:", error);
}
});

cancel(); // Cancel the promise before it resolves
  • Using AbortController:

A modern API that allows you to abort fetch requests.

const controller = new AbortController();
const signal = controller.signal;

fetch('https://example.com', { signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => {
if (error.name === 'AbortError') {
console.log('Fetch aborted');
} else {
console.error('Fetch error:', error);
}
});

// Cancel the request
controller.abort();

Advanced Usage: Customizing Promise Behavior

Timeout in Promises

Sometimes, we want to enforce a timeout for a promise. This can be done by creating a timeout promise that rejects after a certain time:

const timeout = (ms) => new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout!")), ms)
);

Promise.race([fetchData(), timeout(5000)])
.then((data) => console.log(data))
.catch((error) => console.error(error));
  • new Promise((_, reject) => ...) creates a new Promise. The Promise constructor takes a function with two parameters: resolve and reject. These parameters are used to fulfill or reject the Promise.
  • _ is a common convention to indicate that the resolve parameter is not used in this function. It serves as a placeholder to show that it's intentionally ignored. In this case, only the reject function is used to handle the rejection of the Promise after a timeout.
  • setTimeout(() => reject(new Error("Timeout!")), ms) sets a timer to reject the Promise with an error message after the specified time ms (milliseconds).

When you use Promise.race([fetchData(), timeout(5000)]), it races the fetchData Promise against the timeout Promise. If fetchData resolves before the timeout Promise rejects, the result from fetchData is logged. If the timeout Promise rejects first, the error message from timeout is logged.

Conclusion

Promises in Node.js offer a powerful way to manage asynchronous operations, and with a deep understanding of advanced concepts, you can write more robust, maintainable code. While native promise cancellation isn’t straightforward, techniques like cancellation flags, AbortController, or third-party libraries provide viable solutions.

If you want to dive deeper into callbacks, click on the link: 🔍🔗 Callback Explained.

If you love the content and want to support more awesome articles, consider buying me a coffee! ☕️🥳 Your support means the world to me and helps keep the knowledge flowing. You can do that right here: 👉 Buy Me a Coffee

And don’t forget to share your thoughts and feedback! 🤜💬 Let’s learn and grow together! 😊💡 #LearnAndGrow 🌟

🚀 Stay Connected with Me! 🚀

Don’t miss out on more exciting updates and articles!
Follow me on your favorite platforms:

🔗 LinkedIn
📸 Instagram
🧵 Threads
📘 Facebook
✍️ Medium

Join the journey and let’s grow together! 🌟

--

--

techiydude
techiydude

Written by techiydude

I’m a developer who shares advanced Laravel and Node.js insights on Medium.

Responses (1)