The Real Difference Between .catch() and onRejected in JavaScript Promises: An In-Depth Look

Promises have revolutionized asynchronous programming in JavaScript, providing a more readable and maintainable alternative to the notorious callback hell. But with the power of promises comes the responsibility of properly handling errors and rejections. And this is where many developers face a choice: should I use .catch() or onRejected?

On the surface, .catch() and onRejected seem to serve the same purpose – handling rejected promises. But as we‘ll see in this article, there are some critical differences between these two approaches that every JavaScript developer should understand. We‘ll dive deep into their syntax, behavior, and most importantly, how they impact the flow of promise resolution and the microtask execution queue.

By the end of this article, you‘ll have a comprehensive understanding of when to use .catch() vs onRejected, common pitfalls to avoid, and best practices to follow. Let‘s get started!

A Refresher on Promises

Before we can appreciate the differences between .catch() and onRejected, let‘s make sure we‘re on the same page about what promises are and how they work.

A promise is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value. Promises can be in one of three states:

  1. Pending: the initial state, neither fulfilled nor rejected.
  2. Fulfilled: meaning the operation was completed successfully.
  3. Rejected: meaning the operation failed.

Promises provide a .then() method to access their eventual result or error:

someAsyncOperation()
  .then(
    result => {
      // Handle fulfillment
    },
    error => {
      // Handle rejection
    }
  );

The .then() method takes two arguments: onFulfilled and onRejected. These are callbacks that will be invoked when the promise is settled (i.e., either fulfilled or rejected).

There‘s also the .catch() method, which is essentially syntactic sugar for .then(null, onRejected):

someAsyncOperation()
  .then(result => {
    // Handle fulfillment
  })
  .catch(error => {
    // Handle rejection
  });

Both .then() and .catch() return a new promise, allowing you to chain multiple operations:

someAsyncOperation()
  .then(result => {
    return anotherAsyncOperation(result);
  })
  .then(newResult => {
    return yetAnotherAsyncOperation(newResult);
  })
  .catch(error => {
    // Handle any rejection in the chain
  });

With this refresher out of the way, let‘s dive into the differences between .catch() and onRejected.

The Syntax Difference

The first obvious difference between .catch() and onRejected is the syntax. With .catch(), you append a rejection handler as a separate method call:

someAsyncOperation()
  .then(result => {
    // Handle fulfillment
  })
  .catch(error => {
    // Handle rejection
  });

With onRejected, you provide the rejection handler as the second argument to .then():

someAsyncOperation()
  .then(
    result => {
      // Handle fulfillment
    },
    error => {
      // Handle rejection
    }
  );

While this syntax difference might seem trivial, it hints at a more fundamental difference in behavior.

The Behavioral Difference

The key behavioral difference between .catch() and onRejected is what types of rejections they can handle:

  • .catch() will handle rejections from the original promise and any rejections caused by throwing an error in the onFulfilled handler.
  • onRejected will only handle rejections from the original promise. It does not handle rejections caused by throwing an error in the onFulfilled handler.

Let‘s illustrate this with an example:

someAsyncOperation()
  .then(result => {
    throw new Error(‘Oops!‘); // Throws an error
  })
  .catch(error => {
    console.log(error); // Will catch the error
  });

someAsyncOperation()
  .then(
    result => {
      throw new Error(‘Oops!‘); // Throws an error
    },
    error => {
      console.log(error); // Will NOT catch the error
    }
  );

In the first case using .catch(), the error thrown in the onFulfilled handler will be caught. But in the second case using onRejected, that same error will not be caught – it will bubble up the promise chain.

This has important implications when chaining promises. Consider this:

someAsyncOperation()
  .then(result => {
    throw new Error(‘Oops!‘); // Throws an error
  })
  .then(
    result => {
      // This will not run
    },
    error => {
      // This will not catch the error
    }
  );

Here, the error thrown in the first .then() will not be caught by the onRejected handler in the second .then(). The error will continue to propagate until it‘s caught by a .catch() or reaches the end of the chain (in which case it will be swallowed and potentially cause silent failures).

The Event Loop, Task Queue, and Job Queue

To fully understand the difference between .catch() and onRejected, we need to take a detour into how JavaScript handles asynchronous operations under the hood.

In JavaScript, there‘s a single thread that executes code in a run-to-completion manner. This means that once a piece of code starts executing, it will run uninterrupted until it completes.

But what about asynchronous operations like promises that might take an arbitrary amount of time? How are they handled without blocking the main thread?

This is where the event loop comes in. The event loop continuously checks the task queue for tasks to execute. Once the call stack is empty (i.e., there‘s no code currently being executed), it will dequeue a task from the task queue and push it onto the call stack for execution.

But promises introduce an additional complication. Promise reaction handlers (the functions you pass to .then() or .catch()) are not executed through the normal task queue. Instead, they are placed on a special job queue (also known as the microtask queue).

The job queue has a higher priority than the task queue. After a task has finished executing and before the event loop moves on to the next task, it will first empty the job queue. This means that promise reactions are executed before any other tasks.

This might seem like a minor detail, but it has a significant impact on the order in which .catch() and onRejected handlers are invoked.

The Order of Execution

Consider this code:

console.log(‘Start‘);

Promise.resolve()
  .then(() => {
    console.log(‘Promise 1 resolved‘);
    return Promise.reject(‘Oops!‘);
  })
  .then(
    () => {
      console.log(‘Promise 2 fulfilled‘);
    },
    error => {
      console.log(‘Promise 2 rejected:‘, error);
    }
  );

Promise.resolve()
  .then(() => {
    console.log(‘Promise 3 resolved‘);
    throw new Error(‘Oops!‘);
  })
  .catch(error => {
    console.log(‘Promise 3 rejected:‘, error);
  });

console.log(‘End‘);

What do you think the output will be? Let‘s step through it:

  1. ‘Start‘ is logged synchronously.
  2. Promise 1 is resolved, and its reaction handler is queued as a microtask.
  3. Promise 3 is resolved, and its reaction handler is queued as a microtask.
  4. ‘End‘ is logged synchronously.
  5. The event loop is empty, so it starts processing the job queue.
  6. The Promise 1 reaction handler is executed:
    • ‘Promise 1 resolved‘ is logged.
    • Promise 1 is rejected, and the Promise 2 onRejected handler is queued as a microtask.
  7. The Promise 3 reaction handler is executed:
    • ‘Promise 3 resolved‘ is logged.
    • An error is thrown, and the Promise 3 .catch() handler is queued as a microtask.
  8. The event loop continues processing the job queue.
  9. The Promise 2 onRejected handler is executed:
    • ‘Promise 2 rejected: Oops!‘ is logged.
  10. The Promise 3 .catch() handler is executed:
    • ‘Promise 3 rejected: Error: Oops!‘ is logged.

So the final output is:

Start
End
Promise 1 resolved
Promise 3 resolved
Promise 2 rejected: Oops!
Promise 3 rejected: Error: Oops!

This illustrates a few key points:

  • .catch() handlers are queued as microtasks just like .then() handlers.
  • Microtasks are processed in the order they were queued.
  • .catch() handlers will catch rejections that were caused by errors thrown in .then() handlers.

Best Practices and Recommendations

With a deep understanding of how .catch() and onRejected differ, let‘s discuss some best practices for using them.

  1. Use .catch() at the end of promise chains. Since .catch() will handle any rejection in the chain, it‘s a good "catch-all" for unexpected errors. Place it at the end of your chains to avoid silent failures.

  2. Use onRejected for localized error handling. If you need to handle a rejection in a specific way at a certain point in the chain, use onRejected. This allows you to handle the error and potentially recover from it.

  3. Don‘t mix .catch() and onRejected unnecessarily. Mixing them in the same chain can lead to confusing behavior due to the differences in what they catch. Stick to one or the other unless you have a specific reason to use both.

  4. Be mindful of how .catch() swallows rejections. Remember that .catch() will convert rejections into fulfillments (with undefined as the value, unless you return something else). If you need the rejection reason to propagate, rethrow the error or return a rejected promise.

  5. Always return or throw in promise handlers. To avoid confusion, always either return a value (or a promise) or throw an error in your promise handlers. Avoid side effects that aren‘t tied to the promise‘s settlement.

Common Pitfalls and Anti-Patterns

Now let‘s look at some common pitfalls and anti-patterns to avoid when using .catch() and onRejected.

  1. Swallowing errors with an empty .catch().

    someAsyncOperation()
      .then(result => {
        // ...
      })
      .catch(error => {
        // Empty
      });

    This is a common anti-pattern. An empty .catch() will swallow any error and hide potential bugs. Always at least log the error.

  2. Forgetting to return in a .then().

    someAsyncOperation()
      .then(result => {
        someOtherAsyncOperation(); // Forgot to return
      })
      .then(result => {
        // ...
      });

    If you forget to return the promise from someOtherAsyncOperation(), the second .then() will receive undefined as result instead of the actual result of someOtherAsyncOperation().

  3. Nesting promises instead of chaining.

    someAsyncOperation()
      .then(result => {
        someOtherAsyncOperation()
          .then(newResult => {
            // ...
          })
      });

    This nesting makes the code harder to read and reason about. Instead, return the promise and continue the chain:

    someAsyncOperation()
      .then(result => {
        return someOtherAsyncOperation();
      })
      .then(newResult => {
        // ...
      });
  4. Mixing .catch() and onRejected unnecessarily.

    someAsyncOperation()
      .then(
        result => {
          // ...
        },
        error => {
          // ...
        }
      )
      .catch(error => {
        // ...
      });

    This mix of .catch() and onRejected can lead to confusing behavior. Stick to one or the other unless you have a specific reason to use both.

Conclusion

Promises are a powerful tool in JavaScript, but with great power comes great responsibility. Understanding the differences between .catch() and onRejected is crucial for properly handling errors and rejections in your promise chains.

As we‘ve seen, .catch() and onRejected differ in both syntax and behavior. .catch() will handle any rejection in the promise chain, while onRejected only handles rejections from the previous promise. This difference has significant implications for how errors propagate and when handlers are invoked.

We‘ve also explored how promises interact with the JavaScript event loop and job queue, and how this affects the order of execution for .catch() and onRejected handlers.

By following best practices and avoiding common pitfalls, you can write cleaner, more robust promise-based code. Always use .catch() at the end of your chains, use onRejected for localized error handling, and be mindful of how .catch() swallows rejections.

With this deep understanding of .catch() and onRejected, you‘re well-equipped to handle any asynchronous challenge that JavaScript throws your way. Happy coding!

Similar Posts