Error handling with promises

A promise chain shines when something goes wrong. The moment a promise rejects, control skips ahead to the closest rejection handler and everything in between is bypassed. You write your happy-path steps once, then handle failure in a single place at the bottom.

Take this chain. The URL points at a server that doesn’t exist, so fetch rejects, and the .catch at the end scoops up the failure:

fetch('https://nonexistent.invalid') // rejects
  .then(response => response.json())
  .catch(err => alert(err)) // TypeError: failed to fetch (the text may vary)

Notice the .catch sits after a .then, not right next to the fetch. It doesn’t need to be adjacent to the operation that fails. One .catch can guard several .then steps that come before it.

The failure doesn’t have to be a network problem. Maybe the server responds fine, but the body isn’t valid JSON and response.json() throws while parsing. The simplest way to cover every step is to hang a single .catch off the very end of the chain:

fetch('/data/book.json')
  .then(response => response.json())
  .then(book => fetch(`https://api.example.com/authors/${book.authorId}`))
  .then(response => response.json())
  .then(author => new Promise((resolve, reject) => {
    let photo = document.createElement('img');
    photo.src = author.photo_url;
    photo.className = "promise-photo-example";
    document.body.append(photo);

    setTimeout(() => {
      photo.remove();
      resolve(author);
    }, 3000);
  }))
  .catch(error => alert(error.message));

When every step succeeds, that .catch never runs. But if any promise in the chain rejects — a dropped connection, malformed JSON, a bad response, anything — the failure rolls down to it.

.then #1
.then #2 rejects
.then #3 (skipped)
.then #4 (skipped)
↳ jumps to
.catch(error)
A rejection anywhere in the chain skips the remaining .then handlers and lands on the nearest .catch.

Implicit try..catch

The body of a promise executor and every promise handler runs wrapped in an invisible try..catch. If code inside throws, the exception is caught for you and turned into a rejection instead of crashing the surrounding call.

Watch these two snippets. This one throws:

new Promise((resolve, reject) => {
  throw new Error("Kaboom!");
}).catch(alert); // Error: Kaboom!

…and it behaves identically to this one, which rejects on purpose:

new Promise((resolve, reject) => {
  reject(new Error("Kaboom!"));
}).catch(alert); // Error: Kaboom!

The invisible try..catch around the executor grabbed the thrown error and settled the promise as rejected with it. From the outside, throw new Error(...) and reject(new Error(...)) are the same thing.

This isn’t limited to the executor. The same wrapper is around your .then handlers too. Throw inside a .then and the promise it returns becomes rejected, so control jumps to the nearest error handler down the chain:

new Promise((resolve, reject) => {
  resolve("ok");
}).then((result) => {
  throw new Error("Kaboom!"); // rejects the promise
}).catch(alert); // Error: Kaboom!

And it catches more than deliberate throw statements. Any error thrown at runtime counts, including plain bugs. Call a function that doesn’t exist and you get a ReferenceError, which the .catch still receives:

new Promise((resolve, reject) => {
  resolve("ok");
}).then((result) => {
  missingHelper(); // no such function
}).catch(alert); // ReferenceError: missingHelper is not defined

So the final .catch handles two categories at once: rejections you triggered on purpose and accidental errors thrown by the handlers above it.

Try all three routes below. A deliberate throw, an explicit reject, and an accidental bug (calling a function that doesn’t exist) all end up in the same .catch:

interactivethrow, reject, and real bugs all land in .catch

Rethrowing

A .catch at the end of a chain plays the same role as a try..catch around a block of code. You can stack as many .then handlers as you like and finish with one .catch that handles failures from all of them.

In an ordinary try..catch, you can inspect the error and, if you can’t deal with it, rethrow so an outer handler gets a turn. Promises support the same move.

The rule inside a .catch: if you throw, control moves to the next rejection handler down the chain. If you handle the error and return normally, the chain treats the failure as recovered and continues into the next successful .then.

Here the .catch handles the error and finishes cleanly, so the following .then runs:

// the execution: catch -> then
new Promise((resolve, reject) => {

  throw new Error("Kaboom!");

}).catch(function(error) {

  alert("The error is handled, continue normally");

}).then(() => alert("Next successful handler runs"));

Because the .catch returned without throwing, the promise it produced is fulfilled, and the next .then picks up right after it.

Now the opposite case. The handler at (*) looks at the error, decides it only knows how to deal with URIError, and since this isn’t one, it throws the error again. Control skips the .then and lands on the second .catch at (**):

// the execution: catch -> catch
new Promise((resolve, reject) => {

  throw new Error("Kaboom!");

}).catch(function(error) { // (*)

  if (error instanceof URIError) {
    // handle it
  } else {
    alert("Can't handle such error");

    throw error; // throwing this or another error jumps to the next catch
  }

}).then(function() {
  /* doesn't run here */
}).catch(error => { // (**)

  alert(`The unknown error has occurred: ${error}`);
  // don't return anything => execution goes the normal way

});

Execution hops from the first .catch (*) straight to the second one (**), skipping the .then in between because the chain is in a rejected state until something handles the error.

Pick which error to reject with, then watch the path light up. A URIError gets handled by the first .catch, so the .then runs. Any other error is rethrown, the .then is skipped, and the second .catch takes over:

interactiveRecover in the first .catch, or rethrow to the second
.catch(error)
returns a value →
next .then runs
recovered
.catch(error)
throws →
.then skipped
next .catch runs
still failing
Inside a .catch: return normally to resume the happy path, or throw to fall through to the next .catch.

Unhandled rejections

What if nobody handles the error? Say you forgot the .catch at the end:

new Promise(function() {
  noSuchFunction(); // Error here (no such function)
})
  .then(() => {
    // successful promise handlers, one or more
  }); // without .catch at the end!

The promise rejects, and execution looks for the closest rejection handler — but there isn’t one. The error has nowhere to go. It gets stuck, with no code to deal with it.

This is the promise version of a regular uncaught error, and it means the same thing: something went badly wrong and your logic never accounted for it.

When an ordinary error escapes every try..catch, the script dies and a message appears in the console. Unhandled rejections get similar treatment. The JavaScript engine keeps an eye on rejected promises that never gained a handler and raises a global error for each one. Run the snippet above and you’ll see it logged.

In the browser you can listen for these with the unhandledrejection event:

window.addEventListener('unhandledrejection', function(event) {
  // the event object has two special properties:
  alert(event.promise); // [object Promise] - the promise that generated the error
  alert(event.reason); // Error: Kaboom! - the unhandled error object
});

new Promise(function() {
  throw new Error("Kaboom!");
}); // no catch to handle the error

This event is defined by the HTML standard. When a promise rejects and no .catch handles it, the handler fires with an event object describing what happened — event.reason is the error, event.promise is the promise that failed.

The demo below registers an unhandledrejection listener, then lets you create a promise either way. Reject with no .catch and the global handler fires; add a .catch and it stays silent because the rejection was handled:

interactiveWatch unhandledrejection fire (and stay quiet)
promise rejects ✗
no .catch found
window → unhandledrejection
event.reason  →  the Error object    event.promise  →  the failed promise
No matching .catch, so the rejection escalates to the global unhandledrejection handler.

There’s rarely a way to recover this late, so the realistic response is to tell the user something broke and report the incident to your server so you can investigate. Think of it as the last safety net, not a place to run business logic.

Outside the browser the mechanism differs. Node.js exposes a process.on('unhandledRejection', ...) hook that serves the same purpose.

Summary

  • .catch handles every kind of promise failure — a reject() call or an error thrown inside a handler both land there.
  • .then can catch errors the same way when you pass a second argument, which is its rejection handler.
  • Put .catch exactly where you know how to respond to a failure. A good handler inspects the error — custom error classes make this cleaner — deals with the ones it understands, and rethrows the rest, since those are often programming bugs meant for a handler further out.
  • It’s fine to skip .catch entirely when there’s genuinely no way to recover from a failure at that point.
  • Either way, keep a global unhandledrejection handler (and the Node.js equivalent) so stray errors get logged and the user gets told, instead of your app silently falling over.