Free lesson · JavaScript Basic to Advance
Error Handling in Promises
How Errors Flow Through Promise Chains
One of the major problems with callback-style code is error handling — every callback level needs its own error check, and forgetting one means errors silently disappear. Promises solve this with a clean mechanism: errors propagate through a chain until something catches them.
function failingStep() {
return Promise.reject(new Error("Something failed"));
}
failingStep()
.then(v => console.log("Step 1:", v)) // skipped on rejection
.then(v => console.log("Step 2:", v)) // also skipped
.then(v => console.log("Step 3:", v)) // also skipped
.catch(err => console.log("Caught:", err.message));
The .then handlers are all skipped because the chain started with a rejection. The error skips past every .then until it reaches a .catch. This is the propagation mechanism, and it's what makes Promise error handling so much cleaner than callbacks.
This Topic covers how errors flow, how to handle them at different points in a chain, and the common pitfalls that cause errors to silently disappear.
Interactive animation: A failure at the second step skips every handler after it and lands in the single catch at the end.
How Rejection Propagates
A rejected Promise propagates through a .then chain in a specific way:
- A
.thenwith only a success handler (one argument) passes the rejection through to the next.thenor.catch. - A
.thenwith both handlers (two arguments) can catch the error in the second handler. - A
.catchcatches any rejection that reached it.
function failAt(step) {
return new Promise((resolve, reject) => {
if (step === "fail") {
reject(new Error(`Failed at step "${step}"`));
} else {
resolve(step);
}
});
}
// Error propagates past success handlers
failAt("fail")
.then(v => console.log("A:", v)) // skipped
.then(v => console.log("B:", v)) // skipped
.catch(err => console.log("C caught:", err.message));
// Error caught early with two-argument .then
failAt("fail")
.then(
v => console.log("D:", v),
err => console.log("D caught:", err.message)
)
.then(v => console.log("E:", v)); // runs! (the error was caught)
In the first chain, the error skips both .then handlers and hits .catch. In the second chain, the error is caught by the second argument to the first .then, so the next .then runs normally.
The two-argument .then syntax is rarely used in modern code. .then for success + .catch for failure is the standard pattern, and it's clearer.
Interactive animation: Traced side by side, .catch is then(undefined, fn) - the only real difference is which throws each one covers.
Errors Thrown Inside Handlers
If you throw inside a .then handler, the error becomes a rejection of the next Promise in the chain. This is the bridge between sync errors and async errors:
function readData() {
return Promise.resolve("raw data");
}
readData()
.then(data => {
if (data.length < 100) {
throw new Error("Data too short");
}
return data;
})
.then(data => console.log("Got:", data)) // skipped — error thrown above
.catch(err => console.log("Caught:", err.message));
throw inside a .then is equivalent to return Promise.reject(new Error(...)). The thrown error doesn't propagate as a synchronous exception — it propagates through the Promise chain like any other rejection.
This is useful: you can write code that throws errors normally inside .then handlers, and the error handling still works. You don't have to switch between throw and reject styles depending on where you are in the chain.
Interactive animation: A throw inside a .then handler or a promise executor becomes a rejection, and the catch below receives it.
A Useful Pattern: Recovery in catch
.catch doesn't have to terminate the chain. If you return a value from .catch, the chain continues with that value as the success path:
function fetchUser(id) {
if (id === "bad") return Promise.reject(new Error("User not found"));
return Promise.resolve({ id, name: `User_${id}` });
}
fetchUser("bad")
.catch(err => {
console.log("Recovering from:", err.message);
return { id: "fallback", name: "Anonymous" };
})
.then(user => console.log("Final user:", user));
The chain hits .catch, which recovers by returning a fallback object. The next .then runs with that fallback value, not with the error.
This pattern is useful for graceful degradation — when an operation fails, fall back to a default and continue. The chain doesn't have to short-circuit on the first error if you can handle it.
graph TD
A[.then resolved] --> B[next .then]
A --> C[skips .catch]
D[.then rejected] --> E[skips next .then]
E --> F[.catch handles it]
F --> G[next .then with new value]
F --> H[or, .catch rethrows → propagates]
style A fill:#10b981,color:#fff
style D fill:#dc2626,color:#fff
style F fill:#7c3aed,color:#fff
This diagram shows how values and errors flow through a Promise chain. When a then handler resolves successfully, the value flows to the next then and skips any catch in between. When a then handler rejects, the rejection skips subsequent then handlers and is caught by the first catch in the chain. From the catch, you can either return a value to continue the chain with that recovery value, or rethrow to propagate the error further.
Errors Inside Async Callbacks Inside .then
A subtle trap: errors thrown inside synchronous-looking code in a .then are caught. Errors thrown inside async callbacks inside a .then are not necessarily caught — they might escape into the global handler.
Promise.resolve("data")
.then(value => {
setTimeout(() => {
throw new Error("Async error inside .then"); // ⚠️ NOT caught by the chain
}, 50);
return value;
})
.catch(err => console.log("Caught:", err.message));
// The error from setTimeout becomes an "uncaught exception"
// or an "unhandled rejection" — escapes the Promise chain
The setTimeout callback runs after the .then has returned. The error thrown inside it has no Promise to attach to. It propagates to the global error handler, not to the .catch.
The fix is to wrap async work back in a Promise:
Promise.resolve("data")
.then(value => {
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
// some work that might throw
reject(new Error("Async error properly wrapped"));
} catch (err) {
reject(err);
}
}, 50);
});
})
.catch(err => console.log("Caught:", err.message));
By wrapping the async work in a new Promise and returning it, the rejection flows through the chain properly. Modern code mostly avoids this pattern by using async/await (Topic 9.6), which makes the wrapping automatic.
Unhandled Rejections
A Promise that rejects without anyone catching the rejection produces an unhandled rejection. In modern Node and browsers, this is treated as a serious issue:
// Promise rejects, no .catch — unhandled rejection
Promise.reject(new Error("Nobody handles me"));
// In Node, you'll see something like:
// (node:1234) UnhandledPromiseRejectionWarning: Error: Nobody handles me
// (node:1234) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated.
// In future Node.js versions, an unhandled rejection will terminate the process.
In current Node.js, unhandled rejections trigger a warning. In future versions (and in browsers in some cases), they'll terminate the process. This is a feature, not a bug — silently swallowing errors is worse than crashing loudly.
Always handle Promise rejections. Either with .catch, or by letting them propagate to a try/catch block via async/await. If you have a Promise you don't care about the result of, attach a .catch(() => {}) to mark the rejection as handled.
Mistakes That Hide Errors
Three common patterns that cause Promise errors to silently disappear:
1. Returning the wrong Promise (or no Promise) from .then. If a .then doesn't return the Promise from an async call, the chain doesn't wait for it. Errors from that operation become unhandled rejections.
function asyncFail() {
return Promise.reject(new Error("Inner failure"));
}
Promise.resolve()
.then(() => {
asyncFail(); // ⚠️ not returned — its rejection is lost
})
.catch(err => console.log("Caught:", err.message)); // doesn't fire
// The asyncFail rejection becomes an unhandled rejection
The fix: return asyncFail() to make the chain wait for it.
2. Forgetting .catch entirely. A Promise chain without any .catch will silently swallow errors at the end. Always end chains with .catch, or let them propagate via async/await.
3. Catching too early. A .catch only handles errors from the chain before it. If a .then after the .catch rejects, that rejection escapes.
Promise.resolve("first")
.catch(err => console.log("Won't catch"))
.then(() => {
throw new Error("Late failure");
});
// The "Late failure" is unhandled — the .catch was before it
Either place the .catch at the end of the chain, or add another .catch after the later .then.
Common Pitfalls
Warning: The Promise error-handling traps.
Forgetting to return Promises inside
.then. If a.thendoesn't return the Promise from an inner async call, errors from that call are lost.Errors in async callbacks inside
.then. Throwing insidesetTimeoutoraddEventListenercallbacks doesn't propagate to the Promise chain. Wrap the async work in a new Promise to capture errors properly.Missing
.catchat the end of a chain. Always end chains with.catch(or useasync/awaitwithtry/catch). Unhandled rejections are bugs waiting to crash your application.Catching too early. A
.catchonly catches errors from before it. Place catches at the end of chains, or add multiple catches at appropriate points.
Why This Matters in Interviews
Interview: "How does error handling work in Promises?" is a standard mid-level interview question. The expected answer covers rejection propagation, the difference between
.catchand the two-argument.then, and the recovery pattern where.catchreturns a value.Senior questions go deeper: "What's an unhandled rejection? Why is it a problem?" "If a
.thenhandler throws, what happens?" "What's the difference between throwing in a.thenand throwing in asetTimeoutcallback inside a.then?"Topic 9.12 — Interview Lens: Promise and async/await Patterns — covers the deeper treatment.
Try It
Exercise: Predict the output, then run it:
Promise.resolve("start") .then(value => { console.log("A:", value); throw new Error("Mid-chain error"); }) .then(value => { console.log("B:", value); return "from B"; }) .catch(err => { console.log("Caught:", err.message); return "from catch"; }) .then(value => { console.log("C:", value); });What runs and in what order? What value does each handler get? The key insight: errors skip
.thenhandlers,.catchcan recover, and the chain continues after recovery. Once this pattern is automatic, Promise error handling is solved.