DEV Community

Victor Santiago
Victor Santiago

Posted on • Edited on

Promises under the hood

What are Promises?

In JavaScript, promises serve as a mechanism for handling asynchronous operations. They allow for managing the asynchronous nature of tasks like retrieving data from a server, reading a file, or any other operation that may require time to complete. Promises were introduced to enhance the legibility and organization of asynchronous code, particularly compared to the conventional callback-based approaches.

A promise is an object representing the eventual completion or failure of an asynchronous operation. It has three states:

  • Pending: The initial state. The promise is neither fulfilled nor rejected.
  • Fulfilled: The operation completed successfully, and the promise has a resulting value.
  • Rejected: The operation failed, and the promise has a reason for the failure. Here's a basic example of how promises work:
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
  // Simulating an asynchronous operation
  setTimeout(() => {
    const randomNumber = Math.random();
    if (randomNumber > 0.5) {
      resolve(randomNumber); // Operation succeeded
    } else {
      reject("Operation failed"); // Operation failed
    }
  }, 1000);
});

// Handling the Promise
myPromise
  .then((result) => {
    console.log("Success:", result);
  })
  .catch((error) => {
    console.error("Error:", error);
  });
Enter fullscreen mode Exit fullscreen mode

In this example:

  • The Promise constructor takes a function with two parameters: resolve and reject. These are functions that you call to indicate the outcome of the asynchronous operation.
  • The asynchronous operation is simulated with setTimeout. In a real-world scenario, you might have an HTTP request, file reading, or other asynchronous tasks.
  • If the operation is successful, you call resolve with the result. If it fails, you call reject with an error.
  • The then method is used to handle the successful completion of the promise. The function passed to then is called when the promise is resolved.
  • The catch method is used to handle any errors that may occur during the promise execution.

Promises prevent "callback hell" (nested callbacks) and improve readability in asynchronous code. They also establish a consistent method for managing asynchronous operations, simplifying maintenance and comprehension of the code. Additionally, promises pave the way for newer asynchronous features such as async/await to enhance the simplification of asynchronous code even further.

Under the hood

When you create a new promise using the Promise constructor, it receives a function (often referred to as the "executor") as an argument. This executor function takes two parameters: resolve and reject. These are functions provided by the promise implementation, and they are used to transition the promise to either a fulfilled or rejected state.

const myPromise = new Promise((resolve, reject) => {
  // Asynchronous operation
  // ...
  if (operationSucceeded) {
    resolve(result);
  } else {
    reject(error);
  }
});
Enter fullscreen mode Exit fullscreen mode

State Transitions:

A promise begins in the pending state. The executor function is responsible for initialising the asynchronous operation and using resolve or reject to change the promise to the fulfilled or rejected state when the operation completes.

Handlers Registration:

The then and catch methods are used to register callbacks that will be invoked when the promise is resolved or rejected. If a handler is registered before the promise settles, it will be queued and executed once the promise transitions to the corresponding state.

myPromise.then(
  (result) => {
    // Handle fulfillment
  },
  (error) => {
    // Handle rejection
  }
);
Enter fullscreen mode Exit fullscreen mode

Chaining:

Promises support chaining, which means you can chain multiple then calls together. Each then returns a new promise, allowing for a more sequential and readable style of asynchronous code.

myPromise
  .then((result) => {
    // Handle fulfillment
    return modifiedResult;
  })
  .then((modifiedResult) => {
    // Continue processing
  })
  .catch((error) => {
    // Handle rejection in the entire chain
  });
Enter fullscreen mode Exit fullscreen mode

Microtask Queue:

Promises use the microtask queue to execute their callbacks. This is different from the regular task queue (used by events like setTimeout), and it ensures that promise handlers are executed in a timely and predictable manner. Microtasks have higher priority than macrotasks, providing a way to manage asynchronous code execution. More on that, bellow.

Asynchronous Behavior:

Promises enable better handling of asynchronous behavior by allowing code to continue executing while waiting for the asynchronous operation to complete. This is in contrast to synchronous operations, where the code would be blocked until the operation finishes.

Error Propagation:

If an error occurs in any part of the promise chain and is not handled by a catch block, it will propagate down the chain until a rejection handler is encountered.

Microtask Queue

Microtasks vs. Macrotasks

Macrotasks have a lengthier duration and involve several asynchronous operations, such as setTimeout, setInterval, I/O operations, UI rendering. They are commonly triggered by external events, which cause a delay in their execution.

Microtasks, on the other hand, are fleeting and short-lived asynchronous tasks. Microtasks are mainly used by Promises to execute their callbacks. Other examples of microtasks in addition to promise callbacks are the process.nextTick in Node.js and MutationObserver callbacks in the browser.

Execution Order

The main difference between microtasks and macrotasks lies in the order in which they run. Microtasks are given priority over macrotasks, implying that in scenarios where both task types coexist in the queue, the JavaScript engine runs all available microtasks first and then proceeds to the next macrotask. Such prioritization assures timely execution of promise callbacks.

Microtask Queue in Promises

When a promise is settled—either resolved or rejected—its corresponding callbacks registered through then or catch are queued in the microtask queue. This queue is processed at the end of each task in the event loop, guaranteeing that promise callbacks are executed promptly before proceeding to the next rendering or I/O operation.

Illustrating Microtask Queue Priority

Consider the following example:

console.log('Start');

Promise.resolve().then(() => {
  console.log('Promise Microtask 1');
});

setTimeout(() => {
  console.log('SetTimeout Macrotask');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise Microtask 2');
});

console.log('End');
Enter fullscreen mode Exit fullscreen mode

Output:

Start
End
Promise Microtask 1
Promise Microtask 2
SetTimeout Macrotask
Enter fullscreen mode Exit fullscreen mode

In this example, the microtasks (promise callbacks) take precedence and are executed before the macrotask (setTimeout callback), showcasing the priority of microtasks in the event loop.

Thank you for navigating the complexities of promises and the microtask queue with me. I hope this exploration has been valuable and has improved your understanding of asynchronous JavaScript.

Happy coding!

Top comments (0)