Error Handling With Catch And Finally

The last post's guard clause quietly breaks the chain when there's no neighbor. Time to actually catch that instead of pretending it can't happen.

July 11, 20264 min read9 / 22

The last post left a real gap: a guard clause that returns undefined instead of a Promise, silently breaking the next step. Every AJAX call so far has also quietly assumed nothing ever goes wrong. Neither is true, and both get fixed the same way.

When Does a Promise Actually Reject?

fetch rejects for exactly one reason: the request never reached a server at all, no internet connection, DNS failure, that category of problem. A slow server or a bad response still counts as success as far as fetch is concerned, that surprise is worth its own post later.

A deliberately broken domain triggers a real rejection, reliably, without needing to actually go offline to test it.

JavaScript
fetch('https://this-domain-does-not-exist-anywhere.invalid') .then((response) => response.json()) .then((data) => console.log(data));

Run that and nothing renders, an unhandled rejection instead, visible in the console but never actually dealt with in code.

Two Ways to Handle a Rejection

.then() can take a second callback, one for success and one for a rejection. Promise.reject(error), the counterpart to Promise.resolve() from the last post, makes an already-rejected Promise, handy here for testing without a real broken domain.

JavaScript
Promise.reject(new Error('Lost connection')).then( (value) => console.log(value), (error) => console.error('Caught:', error.message) );

That works, but only for a rejection happening at that specific step. A rejection from a later .then() in the chain would sail right past it, uncaught.

A single .catch() at the end of the chain does better: a rejection from anywhere upstream propagates straight down to it, skipping every step in between.

JavaScript
Promise.reject(new Error('Lost connection')).catch((error) => console.error('Caught:', error.message) );

A rejection propagates down through the chain until something catches it, skipping every step in between, and .finally() runs regardless of which path it took. ExpandA rejection propagates down through the chain until something catches it, skipping every step in between, and .finally() runs regardless of which path it took.

One .catch(), wherever it sits, covers the whole chain above it. That's the version worth actually using.

A rejected Promise with nothing handling it is a real defect, not a cosmetic one. Leave one unhandled and it fails silently or crashes something downstream with no explanation. A chain without a .catch() somewhere on it isn't finished.

Giving the User Something to See

Logging an error is a start, not an ending. A real interface needs the user to actually see that something failed.

JavaScript ยท Live Editor
Loading editor...

Hit Run. renderError uses insertAdjacentText instead of insertAdjacentHTML, plain text, no markup needed for a one-line message. The error object itself is a real JavaScript Error, and .message is what gives a readable line instead of the whole object dump.

.finally() Runs No Matter What

That last block, fading the container in, sits inside .finally(), not duplicated inside both the success path and the error path. .finally() can chain onto .catch() at all only because .catch() returns a Promise too, the same rule from a couple of posts back, that every .then()-family method hands back a new Promise whether or not it returns anything.

.finally() runs whether the chain fulfilled or rejected, which is exactly the shape of anything that has to happen either way. Hiding a loading spinner once a request settles, success or failure, is the classic case, this fade-in is a smaller version of the same idea.

What .catch() Still Won't Catch

Search for a country that doesn't exist and the request still succeeds, technically. The server responds, just with a 404 and no matching data, and fetch treats a completed round trip as a fulfilled Promise regardless of the status code.

.catch() never fires for that, there was no rejection to catch. Telling fetch to actually treat a bad status as an error is the next post.

The Essentials

  1. fetch only rejects on a real network failure, not on an HTTP error status like 404.
  2. A second callback passed to .then() only catches a rejection from that one step.
  3. One .catch() at the end of the chain catches a rejection from anywhere above it.
  4. .finally() runs regardless of whether the chain fulfilled or rejected. Anything that must happen either way belongs there.
  5. A 404 response is still a fulfilled Promise, so .catch() won't catch it. That needs an explicit check, next post.