Fundamentals 15 min read

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.

IT Services Circle
IT Services Circle
IT Services Circle
Why .catch() Fails to Catch Sync Errors: Promise.try() & async Solutions

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)); // executes

You 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 runs

Promise.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)); // executes

Use 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

debuggingJavaScriptNode.jsError handlingasync/awaitPromisePromise.trysynchronous exceptions
IT Services Circle
Written by

IT Services Circle

Delivering cutting-edge internet insights and practical learning resources. We're a passionate and principled IT media platform.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.