cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Error handling with promises

Learn how promise rejections travel through chains, where catch belongs, when to recover or rethrow, and how browsers and Node report unhandled rejections.

By the end, promise failures should feel traceable
  • 01
    Follow rejection propagationPredict which then handlers are skipped and which catch receives a rejected promise.
  • 02
    Place catch deliberatelyChoose between local recovery, a final safety net, and the two-argument form of then.
  • 03
    Separate sync throws from async rejectionsAvoid traps where a catch is never attached or a timer throw escapes a promise.

Errors ride the chain

In the Promises lesson you learned that a promise represents one future result. The happy path is only half the story. Real networks fail, users close tabs, JSON can be malformed, and handlers can contain bugs. Promise error handling is the map that tells those failures where to go.

Promise error handling, in one sentence

A rejected promise skips fulfillment handlers until it finds a rejection handler; that handler can recover by returning a value or keep the failure moving by throwing again.

Real-life analogyA sold-out order token

You have an order token and wait for your number. If the item is sold out, you skip pickup until someone can help with the problem. That is promise rejection propagation.

In real life: Your number appears with food
In JavaScript: .then fulfillment handlers process good values
In real life: A sold-out notice skips pickup
In JavaScript: A rejection skips ordinary .then(value => ...) handlers
In real life: The counter offers a replacement
In JavaScript: .catch receives the rejection reason
In real life: You accept it or ask for more help
In JavaScript: catch can fulfill or reject the next promise

Where the analogy stops: A food order is physical. A promise chain is an ordered sequence of callbacks scheduled by JavaScript.

Keep the earlier error lessons nearby: try, catch & finally covers synchronous errors, Throwing & custom errors covers custom error classes, and Error handling strategies helps decide what should be handled locally.

Rejection propagation

STEP THROUGH

Rejection propagation means the failure travels through the chain until a rejection handler appears. Fulfillment handlers are not called with an error value. They are skipped. A catch is shorthand for .then(undefined, onRejected), so it only reacts to the rejection path.

Follow the failure
Step 0 of 7Ready
Your turn: follow the blue line

Set step 2 to fail, then watch the rejection skip forward to catch.

Running in
  1. script
Next: line 7
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
function step(name, value, failAt) {  console.log(name + " saw " + value);  if (failAt === name) throw new Error(name + " broke");  return value + " -> " + name;}   .then((value) => step("1", value, "2"))  .then((value) => step("2", value, "2"))  .then((value) => step("3", value, "2"))  .catch((error) => {    console.log("catch fixed " + error.message);    return "replacement order";  })  .then((value) => console.log("after catch: " + value));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Which step fails?
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Change which step fails and replay the trace. If nothing fails, the catch is skipped. If step 1 fails, steps 2 and 3 do not run at all. The catch receives an Error object, returns a recovery value, and the final then receives that repaired value.

What a promise handler returns or throws
Inside the handlerPromise returned by then/catchNext handler path
Return a plain valueFulfilled with that valueNext fulfillment handler runs
Return another promiseAdopts that promise’s eventual outcomeDepends on the returned promise
Throw an errorRejected with that errorNext rejection handler runs
Return from catchFulfilled, often with a fallbackThe chain is back on the happy path
Throw from catchRejected againA later catch or caller must handle it
Returning from catch fulfills the next promise

.catch(console.log).then(next) continues with undefined, because console.log returns undefined. If you only wanted to log and keep the error, rethrow it after logging.

Where to put catch

INTERACTIVE

A final catch is a safety net for the whole chain. A catch in the middle is a local repair station. The two-argument form .then(onOk, onFail) is sometimes useful, but it has a sharp edge: the second argument does not catch exceptions thrown by the first argument of the same then.

Catch placement lab
Current chainPop out in the code editor (opens in a new tab)JavaScript
Promise.resolve("file")  .then(    (value) => {      console.log("onOk got " + value);      throw new Error("onOk broke");    },    (error) => console.log("then onFail caught " + error.message),  )  .catch((error) => console.log("catch caught " + error.message));
Handler logthen-second
  1. Press Run to execute real promises.
Try it yourself

This is the famous trap: the second argument to the same then does not catch errors thrown by onOk. The following catch does.

All four modes run real Promise chains. Compare source rejection with an exception thrown by the fulfillment handler.

Toggle “source rejects” and “onOk throws.” When the source promise rejects, the second argument of then can catch it. When onOk throws, the promise returned by that then rejects, so only a later handler can catch it.

The two-argument shapePop out in the code editor (opens in a new tab)JavaScript
const sourceRejects = false;

Promise.resolve("file")
  .then(
    (value) => {
      console.log("onOk got " + value);
      throw new Error("onOk broke");
    },
    (error) => console.log("then onFail caught " + error.message),
  )
  .catch((error) => console.log("catch caught " + error.message));
Catch placement choices
PatternBest useCaution
.then(onOk, onFail)Handle rejection of the previous promise right hereDoes not catch errors thrown by onOk
.then(onOk).catch(onFail)Catch source rejection and errors from onOkCan be too broad if you wanted local handling only
Middle .catch(...).then(...)Recover from one known failure and keep goingReturning accidentally can hide a bug
Final .catch(...)Report or display any unhandled failureToo late for local fallback decisions

Rethrowing or recovering

STEP THROUGH

A catch handler should not treat every failure the same. Some errors are expected and recoverable: a missing profile can use a guest profile. Other errors are unknown: a network outage, a corrupt response, or a programming bug should keep traveling until code that can make a safe decision sees it.

Rethrow or recover?
Step 0 of 4Ready
Your turn: follow the blue line

Choose an error type. Known errors are repaired; unknown errors keep traveling.

Running in
  1. script
Next: line 9
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
class NotFoundError extends Error {} function readUser(kind) {  if (kind === "not-found") throw new NotFoundError("missing user");  if (kind === "network") throw new Error("network down");  throw new SyntaxError("bad JSON");}   .then(() => readUser("not-found"))  .catch((error) => {    if (error instanceof NotFoundError) {      return { name: "Guest" };    }    throw error;  })  .then(    (user) => console.log("loaded " + user.name),    (error) => console.log("still failed: " + error.message),  );
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Error type
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

This pattern pairs naturally with custom error classes from Throwing & custom errors. Check for the error type you know how to fix. Rethrow everything else so you keep the stack trace, the message, and the original meaning.

Prefer Error objects as rejection reasonsJavaScript
// Prefer this:
return Promise.reject(new TypeError("id must be a number"));

// Avoid this:
return Promise.reject("id must be a number");

Rejecting with strings technically works, but Error objects are much easier to debug. They carry a message, usually a stack trace, and can be checked with instanceof.

Thrown exceptions vs rejections

WATCH THE TRAPS

Promise handlers convert thrown exceptions into rejections. So does a synchronous throw inside a new Promise executor. But not every throw near promise code is promise-aware. Timing matters.

Real-life analogyA letter that arrives after you left home

A try/catch around code that schedules async work is like waiting by the mailbox only until you leave. If the failure happens later, the old catch is not there anymore.

In real life: You set up mail forwarding, then move away
In JavaScript: A synchronous try/catch finishes before async work fails
In real life: The letter arrives later at the old address
In JavaScript: A timer or promise callback runs after the surrounding stack is gone
In real life: Only the new address can receive it
In JavaScript: Put the handler in the promise chain or inside the async callback

Where the analogy stops: Mail forwarding can last for months. A JavaScript call stack disappears as soon as the synchronous code finishes.

Trap: .catch is never attachedPop out in the code editor (opens in a new tab)JavaScript
function fakeFetch(id) {
  return Promise.resolve({ id, name: "Ada" });
}

function loadUser(id) {
  if (!id) throw new TypeError("id required");
  return fakeFetch(id);
}

loadUser().catch((error) => console.log(error.message));

The call to loadUser() throws before it returns. JavaScript never gets a promise object, so .catch(...) is never called. Fix it by making the function consistently promise-shaped.

Fix 1: make it asyncPop out in the code editor (opens in a new tab)JavaScript
async function loadUser(id) {
  if (!id) throw new TypeError("id required");
  return { id, name: "Ada" };
}

loadUser().catch((error) => console.log(error.message));
Fix 2: return Promise.rejectPop out in the code editor (opens in a new tab)JavaScript
function loadUser(id) {
  if (!id) return Promise.reject(new TypeError("id required"));
  return Promise.resolve({ id, name: "Ada" });
}

loadUser().catch((error) => console.log(error.message));

checking in this browser… Feature-detect before using it; Node 22 does not include it.

ES2025 also adds Promise.try(() => loadUser()), which turns a synchronous throw into a rejection. It is covered in the next lesson, Promise combinators. It is not in every browser and is not in Node 22, so feature-detect it before use.

Reverse trap: a timer throw is not a rejectionPop out in the code editor (opens in a new tab)JavaScript
const promise = new Promise((resolve) => {
  setTimeout(() => {
    throw new Error("timer boom");
  }, 0);
});

promise.catch((error) => console.log("caught " + error.message));

A synchronous throw inside the executor rejects. A throw inside setTimeout happens later, after the executor has returned. That throw is an uncaught exception, and the promise above stays pending forever.

Rejection or uncaught exception?
  • throw inside a .then handler
  • throw synchronously inside a new Promise executor
  • throw inside a setTimeout callback inside the executor
  • return a value from .catch
  • create Promise.reject(error) and never attach a handler
  • a normal .finally(() => close()) after rejection
Try it yourself
0 of 6 correct

Sort each event by what the next promise chain can do with it.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Unhandled rejection alarms

IFRAME DEMO
Real-life analogyAn alarm nobody hears

An unhandled rejection is not invisible. The host environment notices that a promise rejected without a handler and raises an alarm.

In real life: An alarm rings in an empty building
In JavaScript: A promise rejects and no handler is attached
In real life: Security logs the alarm
In JavaScript: The browser reports unhandledrejection
In real life: Someone later acknowledges it
In JavaScript: A late catch can trigger rejectionhandled

Where the analogy stops: A real alarm may call emergency services. JavaScript host behavior varies: browsers report events, while Node can crash the process by default.

Unhandled rejection alarm
<!doctype html><button id="reject">Create unhandled rejection</button><button id="late">Handle it late</button><script>  let promise;  function report(type, reason) {    parent.postMessage({ lesson: "promise-errors", type, reason: String(reason && reason.message || reason) }, "*");  }  addEventListener("unhandledrejection", (event) => {    event.preventDefault();    report("unhandledrejection", event.reason);  });  addEventListener("rejectionhandled", () => report("rejectionhandled", "late catch attached"));  document.getElementById("reject").addEventListener("click", () => {    promise = Promise.reject(new Error("alarm in empty building"));  });  document.getElementById("late").addEventListener("click", () => {    if (promise) promise.catch(() => {});  });</script>
Sandboxed iframesandbox=allow-scripts
  1. Use the buttons inside the sandboxed frame.
Try it yourself
Click inside the sandboxed frame, then read the validated messages.

The iframe runs predefined code only. Its unhandledrejection and rejectionhandled events are posted to the parent after the parent verifies event.source.

The default browser console report is suppressed inside the frame with preventDefault so the page can show the alarm calmly.

Browsers fire unhandledrejection on window. Calling event.preventDefault() suppresses the browser’s default console report. If a handler is attached later, browsers can fire rejectionhandled. In Node.js, use process.on("unhandledRejection") for monitoring; since Node 15 the default is to throw, which crashes the process if nothing handles it.

End chains or return themPop out in the code editor (opens in a new tab)JavaScript
saveDraft(article)
  .then((draft) => publishDraft(draft))
  .then((url) => showSuccess(url))
  .catch((error) => {
    reportError(error);
    showFriendlyMessage("We could not publish yet.");
  });

In application code, end a chain with a catch or return the promise to a caller that will handle it. Do not create floating promises whose failures have nowhere useful to go.

finally and practical patterns

REAL OUTPUT

finally is for cleanup: hide a spinner, release a lock, close a menu, restore a disabled button. It receives no argument. It normally passes the original fulfillment value or rejection reason through. But if finally throws, or returns a rejected promise, that new failure replaces the old outcome.

finally passes through unless it failsPop out in the code editor (opens in a new tab)JavaScript
Promise.reject(new Error("offline"))
  .finally(() => console.log("close spinner"))
  .catch((error) => console.log(error.message));

Promise.resolve("saved")
  .finally(() => {
    throw new Error("cleanup failed");
  })
  .catch((error) => console.log(error.message));

The first chain logs cleanup and still catches offline. The second chain starts fulfilled with saved, but finally throws cleanup failed, so that new error replaces the success.

Where you’ll use this
  • Show a fallback when an optional resource is missing.
  • Rethrow unexpected failures so global reporting sees them.
  • Turn validation errors into friendly UI messages.
  • Use a final catch for logging in scripts and event handlers.
  • Use finally to reset loading state no matter what happened.

Common misconceptions

  • “catch stops the whole chain forever.” Returning from catch fulfills the next promise, so the chain can continue.
  • “The second argument of then catches errors thrown by the first argument.” It handles rejection of the previous promise only.
  • “try/catch around a promise chain catches its future rejection.” It only catches synchronous work done before the stack unwinds.
  • “finally receives the value or error.” It receives no argument and is for cleanup, not transformation.
  • “Unhandled rejections are harmless warnings.” They often indicate lost work; Node’s default can crash the process.
  • “Rejecting with a string is just as good as an Error.” Strings lose stack traces and type checks.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict a recovered chain

Run the chain in your head. Type the two log lines in order.

Starter codePop out in the code editor (opens in a new tab)JavaScript
Promise.reject(new Error("lost"))
  .catch((error) => {
    console.log("fixed " + error.message);
    return "map";
  })
  .then((value) => console.log("use " + value));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeFind which handler catches boom

    Does the second argument run, or does the later catch run?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    Promise.resolve("ok")
      .then(
        () => {
          throw new Error("boom");
        },
        () => console.log("second argument"),
      )
      .catch((error) => console.log("catch " + error.message));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeRethrow the unknown error

      Predict the final log line after the unknown error is rethrown.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      class NotFoundError extends Error {}
      Promise.resolve()
        .then(() => {
          throw new TypeError("bad id");
        })
        .catch((error) => {
          if (error instanceof NotFoundError) return "guest";
          throw error;
        })
        .catch((error) => console.log(error.name + ": " + error.message));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · Practicefinally can replace an outcome

        What prints when cleanup itself fails?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        Promise.resolve("saved")
          .finally(() => {
            throw new Error("cleanup failed");
          })
          .then(
            (value) => console.log(value),
            (error) => console.log(error.message),
          );

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeMake a promise-returning API consistent

          Repair a function that used to throw before returning a promise. What does the fixed program print?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          function loadUser(id) {
            if (!id) return Promise.reject(new TypeError("id required"));
            return Promise.resolve({ id });
          }
          
          loadUser().catch((error) => console.log(error.message));

          Answer, then press Check. Spacing and letter case don’t matter.

            Quiz

            8 QUESTIONS
            Promise error handling quiz · 8 questionsScore: first tries count
            1. Question 1 of 8What is .catch(f) equivalent to?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this promise chain print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              Promise.reject(new Error("missing"))
                .catch((error) => {
                  console.log(error.message);
                  return "default";
                })
                .then((value) => console.log(value));

              Choose an answer to see the explanation.

            3. Question 3 of 8Which handler catches the error thrown by onOk here?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              Promise.resolve("ok")
                .then(
                  () => { throw new Error("boom"); },
                  () => console.log("second")
                )
                .catch((error) => console.log("catch " + error.message));

              Choose an answer to see the explanation.

            4. Question 4 of 8What should a catch do with an unknown error it cannot safely handle?

              Choose an answer to see the explanation.

            5. Question 5 of 8What happens when a normal finally runs after a rejection?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              Promise.reject(new Error("offline"))
                .finally(() => console.log("cleanup"))
                .catch((error) => console.log(error.message));

              Choose an answer to see the explanation.

            6. Question 6 of 8Why does loadUser().catch(...) fail if loadUser throws before returning a promise?

              Choose an answer to see the explanation.

            7. Question 7 of 8What can an unhandled rejection do in a browser?

              Choose an answer to see the explanation.

            8. Question 8 of 8What is the best rejection reason for most application failures?

              Choose an answer to see the explanation.

            Key takeaways

            • A rejected promise skips fulfillment handlers until a rejection handler appears.
            • .catch(f) is .then(undefined, f).
            • Returning from catch recovers; throwing from catch rethrows.
            • A thrown exception inside a promise handler rejects the promise that handler returned.
            • Synchronous throws before a promise is returned cannot be caught by .catch.
            • Always end a chain with catch or return it to a caller that handles it.

            Promise error handling is the art of keeping failures on a visible path: catch what you can fix, rethrow what you cannot, and never leave the alarm unheard.

            Up next: Promise combinators.

            CompleteFrontend Clear concepts. Working examples.