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.
- 01Follow rejection propagationPredict which then handlers are skipped and which catch receives a rejected promise.
- 02Place catch deliberatelyChoose between local recovery, a final safety net, and the two-argument form of then.
- 03Separate 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.
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.
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:
.thenfulfillment 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:
.catchreceives the rejection reason - In real life: You accept it or ask for more help
- In JavaScript:
catchcan 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 THROUGHRejection 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.
Set step 2 to fail, then watch the rejection skip forward to catch.
script
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));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.
| Inside the handler | Promise returned by then/catch | Next handler path |
|---|---|---|
| Return a plain value | Fulfilled with that value | Next fulfillment handler runs |
| Return another promise | Adopts that promise’s eventual outcome | Depends on the returned promise |
| Throw an error | Rejected with that error | Next rejection handler runs |
Return from catch | Fulfilled, often with a fallback | The chain is back on the happy path |
Throw from catch | Rejected again | A later catch or caller must handle it |
.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
INTERACTIVEA 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.
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));- Press Run to execute real promises.
This is the famous trap: the second argument to the same then does not catch errors thrown by onOk. The following catch does.
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.
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));| Pattern | Best use | Caution |
|---|---|---|
.then(onOk, onFail) | Handle rejection of the previous promise right here | Does not catch errors thrown by onOk |
.then(onOk).catch(onFail) | Catch source rejection and errors from onOk | Can be too broad if you wanted local handling only |
Middle .catch(...).then(...) | Recover from one known failure and keep going | Returning accidentally can hide a bug |
Final .catch(...) | Report or display any unhandled failure | Too late for local fallback decisions |
Rethrowing or recovering
STEP THROUGHA 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.
Choose an error type. Known errors are repaired; unknown errors keep traveling.
script
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), );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 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 TRAPSPromise 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.
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/catchfinishes 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.
.catch is never attachedfunction 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.
async function loadUser(id) {
if (!id) throw new TypeError("id required");
return { id, name: "Ada" };
}
loadUser().catch((error) => console.log(error.message));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.
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.
- throw inside a
.thenhandler - throw synchronously inside a
new Promiseexecutor - throw inside a
setTimeoutcallback inside the executor - return a value from
.catch - create
Promise.reject(error)and never attach a handler - a normal
.finally(() => close())after rejection
Sort each event by what the next promise chain can do with it.
Unhandled rejection alarms
IFRAME DEMOAn 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
catchcan triggerrejectionhandled
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.
<!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>- Use the buttons inside the sandboxed frame.
The iframe runs predefined code only. Its unhandledrejection and rejectionhandled events are posted to the parent after the parent verifies event.source.
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.
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 OUTPUTfinally 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.
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.
- 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
finallyto 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 EXERCISESRun the chain in your head. Type the two log lines in order.
Promise.reject(new Error("lost"))
.catch((error) => {
console.log("fixed " + error.message);
return "map";
})
.then((value) => console.log("use " + value));fixed lost
use mapThe catch logs the error message and returns map. That return value becomes the fulfillment value for the next then.
Does the second argument run, or does the later catch run?
Promise.resolve("ok")
.then(
() => {
throw new Error("boom");
},
() => console.log("second argument"),
)
.catch((error) => console.log("catch " + error.message));catch boomBecause the source fulfilled, the first handler runs. Its throw rejects the promise returned by then, so the following catch catches boom.
Predict the final log line after the unknown error is rethrown.
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));TypeError: bad idThe first catch refuses to handle a TypeError and throws it again. The final catch logs the name and message.
What prints when cleanup itself fails?
Promise.resolve("saved")
.finally(() => {
throw new Error("cleanup failed");
})
.then(
(value) => console.log(value),
(error) => console.log(error.message),
);cleanup failedThe finally callback throws, so the chain becomes rejected with the cleanup error instead of fulfilled with saved.
Repair a function that used to throw before returning a promise. What does the fixed program print?
function loadUser(id) {
if (!id) return Promise.reject(new TypeError("id required"));
return Promise.resolve({ id });
}
loadUser().catch((error) => console.log(error.message));function loadUser(id) {
if (!id) return Promise.reject(new TypeError("id required"));
return Promise.resolve({ id });
}
loadUser().catch((error) => console.log(error.message));The invalid-id path returns a rejected promise instead of throwing before the caller can attach catch.
Quiz
8 QUESTIONSQuestion 1 of 8What is
.catch(f)equivalent to?Choose an answer to see the explanation.
Question 2 of 8What does this promise chain print?
Read the code, then predictPromise.reject(new Error("missing")) .catch((error) => { console.log(error.message); return "default"; }) .then((value) => console.log(value));Choose an answer to see the explanation.
Question 3 of 8Which handler catches the error thrown by
onOkhere?Read the code, then predictPromise.resolve("ok") .then( () => { throw new Error("boom"); }, () => console.log("second") ) .catch((error) => console.log("catch " + error.message));Choose an answer to see the explanation.
Question 4 of 8What should a catch do with an unknown error it cannot safely handle?
Choose an answer to see the explanation.
Question 5 of 8What happens when a normal finally runs after a rejection?
Read the code, then predictPromise.reject(new Error("offline")) .finally(() => console.log("cleanup")) .catch((error) => console.log(error.message));Choose an answer to see the explanation.
Question 6 of 8Why does
loadUser().catch(...)fail ifloadUserthrows before returning a promise?Choose an answer to see the explanation.
Question 7 of 8What can an unhandled rejection do in a browser?
Choose an answer to see the explanation.
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.