Skip to main content
Back to Blog

JavaScript .then() and .catch(): Promise Error Handling Explained

Why this site is called ThenCatch: how promise chains pass values and errors along, and the mistakes that swallow them.

Kashyap Thakar
8 min
JavaScriptPromisesAsync
JavaScript .then() and .catch(): Promise Error Handling Explained

.then() and .catch() are the two methods most JavaScript developers meet first when working with promises. They are also where this site gets its name: the tools here exist to help with the work that happens between a request and its handling. This guide explains how a chain really behaves, so you stop losing errors and values along the way.

What a Promise Gives You

A promise is an object representing a value that is not available yet. It is in one of three states: pending, fulfilled with a value, or rejected with a reason. Once it settles it never changes. .then(onFulfilled, onRejected) registers callbacks, and .catch(fn) is shorthand for .then(undefined, fn).

fetch("/api/user/42")
  .then((res) => res.json())
  .then((user) => console.log(user.name))
  .catch((err) => console.error("Request failed:", err));

Every .then() Returns a New Promise

This is the rule that explains almost everything. Whatever a handler returns becomes the value of the next promise in the chain. Return a plain value and the next .then receives it. Return a promise and the chain waits for it. Return nothing and the next handler receives undefined.

Promise.resolve(2)
  .then((n) => n * 2)            // 4
  .then((n) => Promise.resolve(n + 1)) // waits, then 5
  .then((n) => { console.log(n); })    // logs 5, returns undefined
  .then((n) => console.log(n));        // logs undefined

How Errors Travel

A rejection, or an exception thrown inside any handler, skips every following .then until it reaches a .catch. That is why one .catch at the end can cover the whole chain.

Promise.resolve()
  .then(() => { throw new Error("step 1 failed"); })
  .then(() => console.log("never runs"))
  .catch((err) => {
    console.log(err.message); // "step 1 failed"
    return "recovered";
  })
  .then((v) => console.log(v)); // "recovered"

Note the last line: a .catch that returns a value turns the chain back into a fulfilled one. If you want the error to keep propagating, rethrow it.

finally()

.finally(fn) runs whether the promise fulfilled or rejected, receives no arguments, and passes the original result or error through unchanged. Use it for cleanup such as hiding a spinner or releasing a lock, not for transforming values.

showSpinner();
fetch("/api/report")
  .then((r) => r.json())
  .catch(handleError)
  .finally(hideSpinner);

Mistakes That Swallow Errors

1. Forgetting to return

// Bug: the inner promise is not returned, so the chain does not wait
// and its rejection escapes the .catch below
getUser().then((u) => { saveUser(u); }).catch(log);

// Fix
getUser().then((u) => saveUser(u)).catch(log);

2. Nesting instead of chaining

Nested .then calls recreate callback hell and make error flow hard to follow. Return the inner promise and keep the chain flat.

3. A .catch that hides failure

.catch(() => {}) turns every error into silence. At minimum log it. If the caller needs to know, rethrow.

4. Treating HTTP errors as rejections

fetch only rejects on network failure. A 404 or 500 still fulfils, so check res.ok yourself:

fetch(url).then((res) => {
  if (!res.ok) throw new Error("HTTP " + res.status);
  return res.json();
});

Not sure what a status code means? See HTTP status codes explained, or test an endpoint directly in the API Tester.

5. Unhandled rejections

A rejected promise with no handler triggers an unhandledrejection event in browsers and, in modern Node.js, terminates the process by default. Always end a chain with a .catch or return it to a caller that has one.

Running Promises Together

Promise.all([a(), b()])        // rejects as soon as any rejects
Promise.allSettled([a(), b()]) // always resolves, one result per promise
Promise.race([a(), b()])       // first to settle wins
Promise.any([a(), b()])        // first to fulfil; rejects only if all reject

Choose allSettled when partial success is acceptable, for example loading several independent widgets.

.then() vs async/await

async/await is syntax over the same promises. The same chain reads like this:

async function loadUser(id) {
  try {
    const res = await fetch("/api/user/" + id);
    if (!res.ok) throw new Error("HTTP " + res.status);
    return await res.json();
  } catch (err) {
    console.error(err);
    throw err;
  } finally {
    hideSpinner();
  }
}

Prefer await for sequential logic with branches and loops. Prefer .then for short transformations and when composing promises without an async function. They mix freely, because an async function returns a promise.

Quick Reference

  • Each .then returns a new promise; return values to pass them on.
  • A throw or rejection skips to the nearest .catch.
  • A .catch that returns normally recovers the chain.
  • .finally is for cleanup and does not change the result.
  • fetch does not reject on HTTP error statuses.
  • Every chain should end in a handler.

When the data coming out of a promise needs inspecting, the JSON Editor and JWT Decoder are the tools we reach for most.

Written by

Kashyap Thakar

Kashyap is the founder of 11Vertex, a product engineering studio that builds infrastructure, identity, and data systems for startups and growing teams. He writes these guides from problems that came up in client work, and builds the tools on ThenCatch to go with them.

Spotted an error in this article? Corrections are welcome and we update posts rather than quietly pulling them.

Part of the ThenCatch blog. Learn more about us or browse more guides.