Why .catch() Fails to Catch Sync Errors: Promise.try() & async Solutions
The article explains why .catch() doesn't catch synchronous exceptions thrown before a Promise is returned, illustrates common scenarios like validation and JSON.parse, shows debugging clues (Uncaught Error vs unhandledRejection), and presents solutions: Promise.try(), async functions, and try/catch with await, including browser/Node support and a polyfill for older runtimes.
The Problem: .catch() Doesn't Catch Sync Exceptions
The author assumed .catch() acted as a safety net for any error in a Promise-based function. However, a function that synchronously throws before returning a Promise — such as a validation check — bypasses .catch() entirely because no Promise exists to attach the handler to.
Why catch Didn't Trigger
Calling getUser(null) immediately executes the function body. The throw new Error('id is required') occurs before the return fetch(...) statement, so the function never returns a Promise. The subsequent .catch() never gets a chance to run. The exception propagates up the call stack as a regular synchronous throw.
Key rule: .catch() handles Promise rejections, not synchronous throws. Synchronous exceptions must be caught with try/catch around the function call itself.
Common Scenarios
1. Parameter Validation
function createOrder(order) {
if (!order) {
throw new Error("Order is required");
}
return saveOrder(order);
}The validation runs before the Promise-returning saveOrder, so a missing order throws synchronously.
2. JSON.parse()
function loadSettings(value) {
const settings = JSON.parse(value);
return fetch("/api/settings")
.then(() => settings);
}If value is invalid JSON, JSON.parse throws synchronously, before any Promise is returned.
3. Configuration Reading
function getApiKey() {
if (!process.env.API_KEY) {
throw new Error("API_KEY is missing");
}
return process.env.API_KEY;
}
function startService() {
const key = getApiKey();
return connectToService(key);
}Calling startService().catch(handleError) won't catch the synchronous throw from getApiKey() because the Promise chain hasn't been established yet.
Debugging: Identifying the Error Path
In browsers: Uncaught synchronous exceptions appear as Uncaught Error; unhandled Promise rejections appear as Uncaught (in promise) Error.
In Node.js: Synchronous exceptions trigger uncaughtException; unhandled rejections trigger unhandledRejection. If you only listen for one, you may miss the other.
Stack traces also differ: synchronous throws point directly to the caller, while async stacks may look different (though modern runtimes improve async stack traces).
Solution 1: Promise.try()
Promise.try()immediately invokes the given function and wraps the result in a Promise. Normal returns become fulfilled values; synchronous throws become rejections; returned Promises are adopted.
Promise.try(() => getUser(null))
.catch(err => console.error('Caught:', err)); // executesYou can also pass the function and arguments directly:
Promise.try(getUser, userId)
.then(user => console.log(user))
.catch(err => console.error('Caught:', err));Alternatives That Don't Work
Promise.resolve(fn()) fails because fn() executes before Promise.resolve is called, so a synchronous throw still escapes.
Promise.resolve(getUser(null)) // still throws synchronously
.catch(err => console.error(err)); // never runsPromise.resolve().then(fn) catches the exception but changes the execution timing: the callback runs as a microtask, not immediately.
Promise.resolve().then(() => console.log('second'));
console.log('first');
// Output: first, second
Promise.try(() => console.log('first'));
console.log('second');
// Output: first, second Promise.try()preserves immediate invocation while converting sync throws to rejections.
Browser/Node Support
Promise.try()reached Baseline Newly Available in January 2025. Supported versions: Chrome 128+, Firefox 134+, Safari 18.2+, Node 23+. Node 22 LTS does not support it (supported until April 2027). Baseline "widely available" is expected mid-2027. Check your target environments before using in critical paths.
Polyfill for Older Runtimes
const promiseTry = fn => new Promise(resolve => resolve(fn()));This works because the Promise constructor catches exceptions in its executor and converts them to rejections. However, it doesn't support the extra-arguments form Promise.try(fn, a, b).
Solution 2: async Functions
If you control the function, making it async automatically converts synchronous throws into Promise rejections.
async function getUser(id) {
if (!id) {
throw new Error('id is required'); // becomes Promise rejection
}
const res = await fetch(`/api/users/${id}`);
return res.json();
}
getUser(null)
.catch(err => console.error('Caught:', err)); // executesUse async when you can modify the function; use Promise.try() at call sites for third-party or shared synchronous utilities.
Solution 3: try/catch with await
Placing the call inside a try block with await handles both synchronous throws and Promise rejections uniformly.
try {
const user = await getUser(null);
} catch (err) {
console.error('Caught:', err); // both paths end up here
}Because the call is inside try, a synchronous throw is caught directly; a rejected Promise is turned into a throw by await and caught by the same catch. This is a key reason to prefer async/await over chained .then().catch().
Design Rule: Make Error Behavior Explicit
Functions should have a clear contract: either always return a Promise (failures as rejections) or be synchronous (failures as throws). Mixing both in one function confuses callers.
Rule: If any branch returns a Promise, declare the function async . This eliminates synchronous throw paths from the function body.
TypeScript ESLint Rule
The rule @typescript-eslint/promise-function-async flags functions that return a Promise but aren't declared async. Enabling it caught over a dozen such cases in the author's project before they reached runtime.
Conclusion
Promise.try()fixes the call site; async unifies the function's internal error behavior; try/catch with await handles both paths at the consumption point. The real fix is deciding upfront whether a function is allowed to throw synchronously.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
IT Services Circle
Delivering cutting-edge internet insights and practical learning resources. We're a passionate and principled IT media platform.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
