Promise Error Handling
Introduction
Promise errors travel through the same chain as values, but on the rejection path. A rejection keeps moving forward until a rejection handler handles it. .catch(fn) is shorthand for .then(undefined, fn), and what that handler returns determines whether the chain recovers or remains failed.
Why This Matters
Production async bugs often come from swallowed errors, catch blocks in the wrong place, or cleanup code that masks the original failure. Interviews test this because it reveals whether you can reason about failure paths, not just happy paths.
Theory
Rejection propagation
When a promise rejects, JavaScript looks for the next rejection handler in the chain. A .then with only a fulfillment handler does not handle the rejection, so the rejection passes through unchanged. This is why a final .catch can observe errors thrown anywhere above it.
.catch(onRejected) is equivalent to .then(undefined, onRejected). It returns a new promise just like .then.
Throwing and returning in handlers
| Handler action | Next promise |
|---|---|
| Return a fallback value | Fulfilled with that fallback; the error is swallowed. |
| Return a promise | Adopts that promise. |
| Throw a new error | Rejected with the new error. |
Return Promise.reject(reason) | Rejected with that reason. |
A catch in the middle can recover and let later steps run. A catch at the end is usually a terminal reporter unless it rethrows.
Error swallowing
A common bug is logging inside .catch but not rethrowing. If the catch handler returns normally, the chain becomes fulfilled, often with undefined. The caller may think the operation succeeded. If you cannot recover, rethrow the error or return a rejected promise.
.finally semantics
.finally(callback) runs after fulfillment or rejection and receives no value or reason. If it returns normally, the original value or reason passes through. If it throws or returns a rejected promise, it replaces the original outcome with that new rejection.
Unhandled rejections
If a rejection has no handler by the end of the current turn, runtimes report an unhandled rejection. Browsers expose unhandledrejection; Node exposes unhandledRejection. Treat global handlers as telemetry, not normal recovery. Attach local catches where you can add context, recover, or report a precise failure.
Placement rule
Place catches at the level that can make a decision: recover with a fallback, add context and rethrow, or convert the failure into user-facing state. Do not catch only to silence warnings.
Visual Diagrams
p0 rejected with Error
|
v
then with fulfillment handler only -- pass rejection through
|
v
then with fulfillment handler only -- still rejected
|
v
catch handles or rethrows
|
v
next promise is fulfilled if catch returns, rejected if catch throwsA rejection keeps moving until a rejection handler changes it.
Code Examples
Recover vs rethrow
Returning from catch recovers. Throwing from catch keeps the failure path alive.
Browser unhandled rejection telemetry
Global handlers are useful for monitoring, but they should not replace local error handling.
`finally` preserves the original result unless it fails
Cleanup should usually avoid throwing so it does not hide the original outcome.
Playground
Press Run to execute the code and see output here.
Output Prediction
Predict the output #1
1Promise.reject(new Error('fail'))2 .then(function () {3 console.log('then');4 })5 .catch(function (error) {6 console.log('catch ' + error.message);7 return 'ok';8 })9 .then(function (value) {10 console.log(value);11 });12 13console.log('sync');Predict the output #2
1Promise.resolve()2 .then(function () {3 throw new Error('one');4 })5 .catch(function (error) {6 console.log(error.message);7 throw new Error('two');8 })9 .catch(function (error) {10 console.log(error.message);11 });Predict the output #3
1Promise.reject('bad')2 .finally(function () {3 console.log('finally');4 })5 .catch(function (reason) {6 console.log(reason);7 });8 9console.log('sync');Coding Exercises
Retry a promise-returning operation
MediumImplement retry(fn, attempts). fn returns a value or promise. Call it until it fulfils or until all attempts are used. If every attempt fails, reject with the last error.
Interview Questions
1What is the difference between returning a value from `catch` and throwing inside `catch`?
Returning a value from catch recovers the chain: the promise returned by catch is fulfilled with that value. Throwing inside catch rejects the next promise, so the error continues to the next rejection handler. Logging and returning normally swallows the error.
Follow-ups
- How would you add context and preserve failure?
- What should global unhandled rejection handlers be used for?
2How does `.finally` behave with fulfilled and rejected promises?
.finally runs for both outcomes and receives no value or reason. If it returns normally or returns a fulfilled promise, the original outcome passes through. If it throws or returns a rejected promise, the chain rejects with that new reason.
Quiz
1. What does `.catch(handler)` mean?
2. A `catch` logs an error and returns normally. What happens next?
Summary
- Rejections propagate until a rejection handler handles or transforms them.
- `.catch(fn)` is shorthand for `.then(undefined, fn)` and returns a new promise.
- Returning from `catch` recovers; throwing or returning a rejected promise keeps the chain rejected.
- `.finally` is for cleanup and passes the original outcome through unless it fails itself.
Cheat Sheet
Propagation: rejection skips fulfillment-only handlers.
.catch(fn): same as .then(undefined, fn).
Recover: return a value.
Rethrow: throw or return Promise.reject.
finally: cleanup on both paths; original outcome passes through unless finally fails.
Avoid: logging and swallowing errors unintentionally.