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 a failure down the chainPredict which
.thenhandlers are skipped, and which.catchgets the error. - 02Put catch in the right placeChoose between a
.catchthat fixes one step, a.catchat the end as a safety net, and.then(onOk, onFail). - 03Tell a throw from a rejectionSpot a throw that happens before
.catchis reached, and a timer throw that never becomes a rejection.
Errors travel along the chain
In the Promises lesson you met the promise. A promise is an object that stands for a result that isn't ready yet. Later it ends in one of two ways:
- Fulfilled: the work worked, and the promise has a value.
- Rejected: the work failed, and the promise has an error. This error is called the rejection reason.
Things fail all the time. The network drops. A user closes the tab. A server sends broken data. Your own code has a bug. This lesson shows where a failure goes in a promise chain, and how your code can deal with it.
Two words come up again and again. A handler is a function you give to a promise, to run later:
- A success handler runs when the promise is fulfilled. It is the first function you pass to
.then. - An error handler runs when the promise is rejected. It is the function you pass to
.catch, or the second function you pass to.then.
When a promise is rejected, JavaScript skips every success handler until it finds an error handler. That error handler can fix the problem by returning a value. Or it can pass the problem on by throwing the error again.
You order food and get a number. Usually your number is called and you pick up your food. But if your dish is sold out, you don't wait at the pickup line. You go straight to the help desk. The help desk can give you a different dish. Or, if it can't help, it sends you to the manager.
- In real life: Your number is called and you pick up your food
- In JavaScript: A
.thensuccess handler gets the value - In real life: Your dish is sold out, so you skip the pickup line
- In JavaScript: A rejection skips the
.thensuccess handlers - In real life: You go to the help desk
- In JavaScript:
.catchgets the rejection reason - In real life: The help desk gives you another dish, or sends you to the manager
- In JavaScript:
.catchreturns a value, or throws the error again
Three earlier lessons help here. try, catch & finally covers errors in code that runs straight through. Throwing & custom errors covers your own error classes. Error handling strategies helps you decide which code should deal with an error.
How a rejection skips ahead
STEP THROUGHWhen a promise in a chain is rejected, the error moves down the chain. JavaScript skips each success handler. It does not call them with the error. It stops at the first error handler it finds.
.catch(f) is a short way to write .then(undefined, f). It gives no success handler, only an error handler. So it only runs when there is an error.
Promise.resolve("tea")
.then(() => {
throw new Error("kettle broke");
})
.catch((error) => console.log("caught " + error.message));Line 1 makes a promise that is already fulfilled with "tea". The .then handler on lines 2 to 4 throws an error. A .then call always gives back a new promise, and the throw rejects that new promise. So line 5's .catch runs and prints caught kettle broke.
Step 2 fails. Watch the error skip ahead to the 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));catch. catch returns a new value, so the last then runs again.Change which step fails, then step through again:
- If no step fails, the
catchon line 11 is skipped. - If step 1 fails, steps 2 and 3 never run.
- In every failing case, the
catchgets anErrorobject and returns"replacement order". The last.thenon line 15 gets that new value.
Each handler gives back a new promise. What the handler does decides how that promise ends. Read each row from left to right.
| What the handler does | How the promise it gives back ends | Which handler runs next |
|---|---|---|
It returns a plain value, like "done". | It is fulfilled with that value. | The next success handler runs. |
| It returns another promise. | It waits for that promise and ends the same way. | It depends on how that other promise ends. |
| It throws an error. | It is rejected with that error. | The next error handler runs. |
A .catch handler returns a value. | It is fulfilled, often with a backup value. | The chain is back on the success path. |
A .catch handler throws. | It is rejected again. | A later .catch, or the code that called you, must handle it. |
In .catch(console.log).then(next), console.log prints the error and returns undefined. So the chain is fixed, and next runs with undefined. If you only want to log the error and keep the failure, throw the error again after you log it.
Where to put catch
INTERACTIVEWhere you put an error handler decides which failures it sees:
- A
.catchat the end of a chain is a safety net. It sees any failure that gets that far. - A
.catchin the middle fixes one problem, and the chain keeps going. .then(onOk, onFail)takes two handlers at once:onOkfor success andonFailfor failure.
The two-handler form has a trap. onFail does not catch an error thrown by onOk in the same .then. It only sees a failure of the promise before that .then.
Promise.reject(new Error("offline"))
.then(
() => console.log("success"),
(error) => console.log("source handled: " + error.message),
);
Promise.resolve("file")
.then(() => {
throw new Error("bad file");
}, (error) => console.log("same then: " + error.message))
.catch((error) => console.log("later catch: " + error.message));The first chain starts with a rejected promise, so its second handler, onFail, runs and prints source handled: offline. The second chain starts with a fulfilled promise. Its first handler throws bad file. The second handler in the same .then doesn't see that error. The later .catch on line 11 does, and prints later catch: bad file.
onOk and onFail sit in the same then. When onOk throws, onFail can't catch it, so the error goes on to the next catch.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 run the real promise chain.
This is the trap: onFail sits in the same then as onOk, so it can't catch an error thrown by onOk. The catch after it does.
onOk handler that throws.The first button makes the first promise fulfill or reject. The second makes onOk throw or return. The other buttons choose where the error handler goes. Then press Run. When the first promise rejects, onFail catches it. When onOk throws, the promise from that .then is rejected, so only a handler further down 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));Each row is one place to put an error handler. Read across to see when to use it and what to watch out for.
| Where the error handler is | When to use it | What to watch out for |
|---|---|---|
.then(onOk, onFail) | You want to handle a failure of the promise just before this .then. | It does not catch an error thrown by onOk. |
.then(onOk).catch(onFail) | You want to catch a failure of the earlier promise and an error from onOk. | It may catch more failures than you meant to. |
A .catch(...) in the middle, with more .then calls after it | You can fix one known problem and keep going. | Returning a value by mistake can hide a bug. |
A .catch(...) at the end | You want to report or show any failure that got this far. | It is too late to pick a backup value for one step. |
Fix it or pass it on
STEP THROUGHA .catch handler should not treat every error the same way. Some errors are expected, and you know how to fix them. For example, if a user profile is missing, you can show a guest profile instead.
Other errors are a surprise: the network is down, the data is broken, or there is a bug. For those, rethrow the error. Rethrowing means throwing the same error again, so it keeps moving down the chain. Later code that knows more can then decide what to do.
Promise.reject(new TypeError("bad id"))
.catch((error) => {
if (error instanceof RangeError) return "fallback";
throw error;
})
.catch((error) => console.log(error.name + ": " + error.message));Line 1 makes a promise rejected with a TypeError. On line 3, instanceof checks the error's type: error instanceof RangeError is true only if the error is a RangeError. This one is a TypeError, so line 4 throws the same error again. The second .catch prints TypeError: bad id.
Pick an error type. Known errors get fixed; unknown errors keep moving.
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");} Promise.resolve() .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), );catch checks the error. A NotFoundError is fixed with a guest user; any other error is thrown again, so the next error handler prints still failed.This works well with the error classes from Throwing & custom errors. Check for the error type you know how to fix. Throw every other error again. That keeps its message, its type, and its stack trace: the list of function calls that led to the error.
// Prefer this:
return Promise.reject(new TypeError("id must be a number"));
// Avoid this:
return Promise.reject("id must be a number");Promise.reject(reason) makes a promise that is already rejected with that reason. You can reject with plain text, but an Error object is much easier to debug. It has a message and usually a stack trace, and you can check its type with instanceof.
Thrown errors versus rejections
WATCH THE TRAPSA throw inside a promise handler becomes a rejection. So does a throw inside the executor: the function you pass to new Promise(...), which runs right away. But not every throw near promise code becomes a rejection. It depends on when the throw happens.
function request(url) {
if (url === "/offline") return Promise.reject(new TypeError("network failed"));
return Promise.resolve({ ok: false, status: 404 });
}
request("/offline").catch((error) => console.log(error.message));
request("/missing").then((response) => console.log(response.status + " is a response"));This small request function copies how fetch behaves. fetch is the browser's built-in way to ask a server for data:
- If the network fails, the promise is rejected. Line 6's
.catchprintsnetwork failed. - If the server answers "404, not found", the promise is still fulfilled. The answer has
ok: false. Line 7 prints404 is a response.
So real code checks response.ok, and throws an error itself when the answer is not OK.
You wait by the mailbox for a letter, then you go out. The letter arrives after you left, so nobody takes it. A try/catch around code that starts later work is like that. If the work fails later, the try/catch is already gone.
- In real life: You wait by the mailbox, then go out
- In JavaScript: A
try/catchruns once, then it is done - In real life: The letter arrives after you left
- In JavaScript: A timer or promise handler fails later
- In real life: Ask someone who will be home later to take it
- In JavaScript: Put a
.catchon the promise, or atry/catchinside the later function
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));On line 10, loadUser() runs first. No id is passed, so line 6 throws right away. The function never returns a promise, so JavaScript never reaches .catch(...), and the error goes uncaught. To fix it, make the function always return a promise, even when it fails:
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));An async function always returns a promise. If it throws, that promise is rejected. Fix 2 does the same thing by hand with Promise.reject. Both print id required.
.catch() is reached. An async function turns the throw into a rejected promise, so .catch() gets the error.Checking this browser… Check that it exists before you use it; Node 22 doesn't have it.
A newer tool, Promise.try(() => loadUser()), also turns a throw into a rejected promise. It was added in ES2025, the 2025 edition of JavaScript. The next lesson, Promise combinators, covers it. Not every browser has it yet, and Node 22 doesn't, so check that it exists before you use it.
const promise = new Promise((resolve) => {
setTimeout(() => {
throw new Error("timer boom");
}, 0);
});
promise.catch((error) => console.log("caught " + error.message));A throw inside the executor itself rejects the promise. But setTimeout runs its function later, after the executor has finished. So the throw on line 3 is an uncaught error, not a rejection. The .catch on line 7 never runs, and the promise stays pending (not finished) forever.
- A
.thenhandler throws an error - The
new Promiseexecutor throws while it runs - A
setTimeoutfunction inside the executor throws later - A
.catchhandler returns a value Promise.reject(error)with no handler ever attached- A normal
.finally(() => close())after a rejection
Sort each event by what a promise chain can do with it.
Rejections nobody handles
IFRAME DEMOAn unhandled rejection is a promise that was rejected while no error handler was attached to it.
An alarm rings in an empty building. Nobody is there to deal with it. But the security company still gets a message and writes it down. In the same way, the browser notices a rejection that nobody handled, and reports it.
- In real life: An alarm rings in an empty building
- In JavaScript: A promise is rejected and has no error handler
- In real life: The security company writes it in its log
- In JavaScript: The browser fires an
unhandledrejectionevent - In real life: Someone arrives later and turns it off
- In JavaScript: A late
.catchcan firerejectionhandled
<!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>- Press the buttons inside the frame below.
The frame is a small, walled-off page (a sandbox) that runs only this fixed code. It sends its unhandledrejection and rejectionhandled events to this page, which first checks that each message really came from the frame.
preventDefault stops the browser's usual console error, so this page can show the event instead.Here is what each tool does:
- The browser fires an
unhandledrejectionevent onwindow. An event is a message your code can listen for. - Calling
event.preventDefault()stops the browser from printing its usual error in the console. - If a handler is attached later, the browser can fire
rejectionhandled. - In Node.js, listen with
process.on("unhandledRejection"). Since Node 15, an unhandled rejection with no listener stops the whole program.
saveDraft(article)
.then((draft) => publishDraft(draft))
.then((url) => showSuccess(url))
.catch((error) => {
reportError(error);
showFriendlyMessage("We could not publish yet.");
});This chain saves a draft, publishes it, and shows the link. The .catch at the end reports any failure and shows the user a friendly message. In your own code, always do one of two things: end the chain with a .catch, or return the promise to the code that called you, so it can handle it. A promise that does neither is called a floating promise: if it fails, the error has nowhere useful to go.
finally, and where you'll use this
REAL OUTPUT.finally(f) runs f when the promise ends, whether it was fulfilled or rejected. Use it for cleanup, such as hiding a loading spinner, closing a menu, or turning a button back on. Here is how it behaves:
fgets no value and no error.- Usually, the original value or error passes straight through to the next handler.
- But if
fthrows, or returns a rejected promise, that new error takes the place of the old result.
Promise.resolve("saved")
.finally(() => console.log("cleanup"))
.then((value) => console.log("value " + value));Line 2 prints cleanup first. Its function returns nothing, but that doesn't replace "saved". Line 3 still gets the original value and prints value saved.
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 prints close spinner, then its .catch still gets the offline error. The second chain starts fulfilled with "saved". But its finally throws cleanup failed, so that new error replaces the success, and the .catch prints it. The two chains take turns, one step at a time. The first chain needs a few extra steps to pass its error through finally, so the console shows close spinner, cleanup failed, then offline.
- Show a backup when something optional is missing, like a guest profile.
- Throw unexpected errors again, so your error reporting sees them.
- Turn form errors into friendly messages for the user.
- Put a
.catchat the end of chains in scripts and click handlers, to log failures. - Use
finallyto hide a loading spinner, whatever happened.
Common mix-ups
- "catch ends the whole chain." No. When a
.catchhandler returns, the next promise is fulfilled, so the chain keeps going. - "The second handler of then catches errors from the first handler." No. It only catches a failure of the promise before that
.then. - "A try/catch around a promise chain catches its later rejections." No. It only catches errors thrown while the code inside it is running right now.
- "finally gets the value or the error." No. It gets nothing. It is for cleanup, not for changing the result.
- "Unhandled rejections are harmless warnings." No. They often mean work was lost, and by default Node.js stops the program.
- "Rejecting with plain text is as good as an Error." No. Plain text has no stack trace, and you can't check its type with
instanceof.
Practice exercises
5 EXERCISESRun the chain in your head. Type the two printed 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 prints the error message and returns "map". That value fulfills the next promise, so the last .then prints use map.
Does the second handler 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 boomThe first promise is fulfilled, so the first handler runs. Its throw rejects the promise that .then gives back, so the .catch after it prints catch boom.
The first .catch throws the error again. Predict the last printed line.
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 doesn't fix a TypeError, so it throws it again. The last .catch prints the error's name and message.
What prints when the 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 function throws, so the chain is rejected with the cleanup error instead of fulfilled with "saved". The second handler prints the message.
This function used to throw before it returned a promise. It is fixed now. What does the 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));When id is missing, the function now returns a rejected promise instead of throwing. So .catch is reached, and it prints the message.
Quiz
8 QUESTIONSQuestion 1 of 8
.catch(f)is a short way to write which call?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 8
onOkthrows here. What does this print?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 8A
.catchgets an error it doesn't know how to fix. What should it do?Choose an answer to see the explanation.
Question 5 of 8A normal
finallyruns after a rejection. What does this print?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 8
loadUserthrows before it returns a promise. Why doesn'tloadUser().catch(...)catch the error?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 should you reject a promise with, for most failures in an app?
Choose an answer to see the explanation.
Key takeaways
- A rejected promise skips every success handler until it reaches an error handler.
.catch(f)is a short way to write.then(undefined, f).- Returning from
.catchfixes the chain. Throwing from.catchpasses the error on. - A throw inside a promise handler rejects the promise that the handler's
.thenor.catchgives back. - A throw that happens before a promise is returned can't be caught by
.catch. - Always end a chain with
.catch, or return it to code that will handle it.
In one line: keep every failure on a path someone can see. Fix the errors you know, throw the rest again, and never leave a rejection unhandled.
Up next: Promise combinators.