Table of Contents#
- Understanding Promise Chaining and Rejection Propagation
- Common Culprits Behind Unpropagated Rejections
- Solutions and Best Practices
- Conclusion
- References
1. Understanding Promise Chaining and Rejection Propagation#
Before troubleshooting, let’s recap how promise chaining works. A promise has three states: pending, fulfilled, or rejected. When you call .then() on a promise, it returns a new promise, allowing you to chain subsequent operations.
- If a promise in the chain fulfills, the next
.then()’sonFulfilledhandler runs with the resolved value. - If a promise rejects, the chain skips
onFulfilledhandlers and looks for the nextonRejectedhandler (either via.catch()or the second argument to.then()).
Expected Behavior: A rejection should propagate down the chain until a .catch() (or onRejected handler) catches it. If no handler exists, the runtime throws an "unhandled promise rejection" warning/error.
Example: Normal Rejection Propagation#
// A promise that rejects after 1 second
const faultyPromise = () =>
new Promise((resolve, reject) =>
setTimeout(() => reject(new Error("Something went wrong!")), 1000)
);
// Chained promises with a .catch() at the end
faultyPromise()
.then(result => console.log("Step 1:", result)) // Skipped (promise rejects)
.then(result => console.log("Step 2:", result)) // Skipped
.catch(error => console.error("Caught error:", error.message)); // Runs!
// Output: "Caught error: Something went wrong!"Here, the rejection propagates to the final .catch(), as expected. But why does this break in real-world code?
2. Common Culprits Behind Unpropagated Rejections#
Let’s explore the most frequent reasons rejections fail to propagate, with examples and fixes.
2.1 Missing .catch() at the End of the Chain#
Problem: If the chain lacks a final .catch(), unhandled rejections may go undetected (or only surface as runtime warnings). While modern environments (browsers, Node.js) log unhandled rejections, they won’t trigger your custom error-handling logic.
Example:
fetch("https://api.example.com/data")
.then(response => response.json())
.then(data => process(data)); // No .catch()!
// If fetch fails (e.g., network error), the rejection is unhandled.
// Node.js: "UnhandledPromiseRejectionWarning: Error: Failed to fetch"
// Browser: "Uncaught (in promise) Error: Failed to fetch"Fix: Always add a .catch() at the end of the chain to handle uncaught rejections:
fetch("https://api.example.com/data")
.then(response => response.json())
.then(data => process(data))
.catch(error => {
console.error("Request failed:", error);
// Handle the error (e.g., retry, show UI message)
});2.2 Forgetting to Return Promises in .then() Handlers#
Problem: Inside a .then() handler, if you create a new promise but don’t return it, the chain doesn’t wait for that promise to resolve/reject. The parent promise (from .then()) resolves immediately with undefined, and any rejection from the inner promise is lost.
Example:
const fetchData = () =>
new Promise(resolve => setTimeout(() => resolve({ id: 1 }), 1000));
const processData = (data) =>
new Promise((resolve, reject) =>
setTimeout(() => reject(new Error("Invalid data!")), 500) // This rejects!
);
// Bug: processData is called but not returned
fetchData()
.then(data => {
processData(data); // 🔴 Not returned!
})
.catch(error => {
console.error("Error:", error.message); // ❌ Never runs!
});Why It Fails: The .then() handler returns undefined (since processData isn’t returned), so the chain resolves with undefined immediately. The rejection from processData is never linked to the chain, causing it to be unhandled.
Fix: Return the inner promise to link it to the chain:
fetchData()
.then(data => {
return processData(data); // ✅ Return the promise!
})
.catch(error => {
console.error("Error:", error.message); // ✅ Runs: "Error: Invalid data!"
});2.3 Handling Rejections Without Re-Throwing#
Problem: A .catch() handler "resolves" the promise chain by default. If you handle a rejection with .catch() but don’t re-throw the error, subsequent .then() handlers will run with the resolved value (from .catch()), and the rejection stops propagating.
Example:
doRiskyTask()
.then(result => result.value)
.catch(error => {
console.log("Handled error:", error.message); // "Handled error: Oops!"
// 🔴 No re-throw; chain continues with resolved promise
})
.then(finalValue => {
console.log("Final value:", finalValue); // "Final value: undefined" (since .catch() returns undefined)
});Why It Fails: The .catch() handles the rejection, so the chain proceeds with a resolved promise (resolved to the return value of .catch(), which is undefined here). Subsequent .then() handlers execute as if no error occurred.
Fix: Re-throw the error in .catch() to propagate it further:
doRiskyTask()
.then(result => result.value)
.catch(error => {
console.log("Handled error:", error.message);
throw error; // ✅ Re-throw to keep the rejection alive
})
.then(finalValue => {
console.log("Final value:", finalValue); // ❌ Skipped (chain is rejected)
})
.catch(finalError => {
console.error("Final error handler:", finalError.message); // ✅ Runs: "Final error handler: Oops!"
});2.4 Synchronous Errors or Swallowed Exceptions in Handlers#
Problem: Synchronous errors (e.g., JSON.parse("invalid")) or exceptions caught by try/catch inside .then()/.catch() handlers can prevent rejections from propagating.
Subcase A: Uncaught Synchronous Errors#
A synchronous error in a .then() handler automatically rejects the returned promise. But if you’re unaware, you might think the error is "lost."
Example:
fetchData()
.then(data => {
const parsed = JSON.parse(data); // data is invalid JSON (throws synchronously)
return parsed;
})
.catch(error => {
console.error("Error:", error.message); // ✅ Catches the synchronous error!
});This actually works: the synchronous error rejects the .then() promise, and .catch() catches it. The "problem" here is usually awareness—developers may not realize synchronous errors in handlers propagate as rejections.
Subcase B: Swallowed Exceptions with try/catch#
If you use try/catch inside a handler and don’t re-throw the error, the promise resolves instead of rejecting, swallowing the error.
Example:
fetchData()
.then(data => {
try {
return JSON.parse(data); // Invalid JSON (throws)
} catch (e) {
console.log("Parsing failed:", e.message); // "Parsing failed: Unexpected token"
// 🔴 Error is swallowed; .then() returns resolved promise with undefined
}
})
.catch(error => {
console.error("Chain error:", error); // ❌ Never runs!
});Why It Fails: The try/catch inside .then() catches the error but doesn’t re-throw it. The .then() handler returns undefined, so the chain resolves with undefined, and the outer .catch() never triggers.
Fix: Re-throw the error in the catch block to propagate it:
fetchData()
.then(data => {
try {
return JSON.parse(data);
} catch (e) {
console.log("Parsing failed:", e.message);
throw e; // ✅ Re-throw to reject the promise
}
})
.catch(error => {
console.error("Chain error:", error.message); // ✅ Runs: "Chain error: Unexpected token"
});3. Solutions and Best Practices#
To ensure rejections propagate reliably:
- Always Return Promises in
.then(): Link inner promises to the chain by returning them. - Add a Final
.catch(): Catch unhandled rejections at the end of every chain. - Re-Throw in
.catch()When Needed: If errors should propagate beyond a handler, re-throw them. - Avoid Silent
try/catch: Never swallow errors in handlers unless explicitly intended (e.g., recovery logic). - Prefer
async/awaitfor Readability:async/awaitoften makes propagation clearer (errors bubble up naturally withtry/catch):
// Using async/await (rejection propagates to catch)
async function fetchAndProcess() {
try {
const data = await fetchData();
const result = await processData(data); // Rejections here bubble up
return result;
} catch (error) {
console.error("Error:", error.message);
throw error; // Re-throw if needed
}
}4. Conclusion#
Promise rejection propagation fails most often due to simple oversights: missing returns, unhandled rejections, or swallowed errors. By following best practices—returning promises, using final .catch() handlers, and re-throwing when necessary—you can ensure errors flow predictably through your chains.
Remember: promises are designed to make async code manageable, but their behavior requires careful attention to how handlers return and propagate values. With these insights, you’ll spend less time debugging silent failures and more time building robust applications.