Back

Writing Cleaner Async Chains with Promise.try

Writing Cleaner Async Chains with Promise.try

If you’ve ever written a promise chain that starts with a function that might be synchronous or might be asynchronous, you’ve probably run into an awkward problem: where do you put your .catch()?

Synchronous errors thrown before a promise is returned won’t be caught by .catch() unless you’re already inside a promise context. Promise.try() solves this cleanly by giving you a single, consistent entry point for any promise chain — regardless of whether the function you’re calling is sync, async, or somewhere in between.

Key Takeaways

  • Synchronous throws inside functions that sometimes return promises can escape .catch() entirely, leading to unhandled exceptions.
  • Promise.try() runs a function immediately and wraps both sync returns and sync throws into a proper promise, giving you a unified error-handling path.
  • Unlike Promise.resolve(fn()), it catches sync errors. Unlike Promise.resolve().then(fn), it executes eagerly without deferring to a microtask.
  • It’s best suited for .then()-based chains with mixed sync/async entry points — not as a replacement for async/await.

The Problem: Sync Errors That Escape Your .catch() Chain

Consider a data loader that reads from a cache synchronously or fetches from an API asynchronously depending on conditions:

function loadData(key) {
  const cached = getFromCache(key) // may throw synchronously
  if (cached) return cached
  return fetch(`/api/data/${key}`).then(res => res.json())
}

loadData('user-1')
  .then(data => render(data))
  .catch(err => handleError(err)) // ⚠️ Won't catch sync throws from loadData

If getFromCache throws synchronously, that error is never caught by .catch(). The throw happens before any promise exists, so it escapes the chain entirely and becomes an unhandled exception.

There’s a second subtlety here worth noting: when loadData returns a cached value directly (a non-thenable), calling .then() on it will also fail because plain values don’t have a .then() method. This function is inherently fragile — it returns a promise in one branch and a raw value in another. Promise.try() addresses both issues by always producing a promise.

How Promise.try() Fixes This

Promise.try(fn) executes the provided function immediately and wraps the result in a promise. If the function returns a plain value, it resolves with that value. If it returns a promise, it adopts that promise. If it throws synchronously, it converts that error into a rejection.

This gives you a single entry point for handling both sync and async errors through the same .catch():

Promise.try(() => loadData('user-1'))
  .then(data => render(data))
  .catch(err => handleError(err)) // ✅ Catches both sync throws and async rejections

No special casing. No wrapping in try/catch before starting the chain. Everything flows through .catch() as expected.

How It Differs from Promise.resolve().then(fn) and Promise.resolve(fn())

These two patterns are commonly used as workarounds, but they behave differently in important ways.

Promise.resolve(fn()) calls fn() immediately, outside of a promise context. A synchronous throw here is an uncaught exception, not a rejection.

Promise.resolve().then(fn) defers execution of fn to a microtask. This means fn doesn’t run immediately — which can cause subtle timing issues and makes the behavior less predictable when you need eager execution.

Promise.try(fn) runs fn immediately and captures any synchronous throw as a rejection. It’s the most predictable of the three for starting promise chains.

PatternRuns fn immediatelyCatches sync throws
Promise.resolve(fn())
Promise.resolve().then(fn)
Promise.try(fn)

Practical Frontend Use Cases

Promise.try() fits naturally into JavaScript async patterns where behavior depends on runtime conditions:

Utility functions that may return cached data synchronously or fetch it asynchronously:

Promise.try(() => getUserFromCacheOrAPI(userId))
  .then(updateUI)
  .catch(showErrorBanner)

Conditional async workflows where a validation step might throw before any async work begins:

Promise.try(() => {
  validateInput(formData) // throws if invalid
  return submitForm(formData) // returns a promise
})
  .then(handleSuccess)
  .catch(handleError)

Browser Support and Compatibility

Promise.try() was included in ECMAScript 2025 (ES2025) and is supported in Chrome 128+, Firefox 134+, Safari 18.2+, and Node.js 22.7.0+. You can verify current browser support on Can I Use, which tracks implementation status across major browsers and runtimes.

For older environments, you can polyfill it with a simple wrapper:

Promise.try = Promise.try || function(fn) {
  return new Promise(resolve => resolve(fn()))
}

This works because the new Promise executor runs synchronously, so fn() is called immediately. If fn() throws, the Promise constructor catches it and turns it into a rejection. If fn() returns a thenable, resolve adopts it.

Conclusion

Promise.try() isn’t a replacement for async/await. It’s a small, focused tool for one specific situation: starting a promise chain when the entry point might throw synchronously or return a mix of values and promises.

If you’re already inside an async function, a try/catch block handles both cases naturally. But when you’re working with .then() chains — especially around utility functions or data loaders with conditional logic — Promise.try() keeps your error handling consistent and your chains clean.

FAQs

Yes. If the function you pass to Promise.try() is async, it returns a promise, and Promise.try() adopts that promise. It works the same as passing any promise-returning function. The main benefit of Promise.try() is for functions that might not return a promise at all, or that might throw before returning one.

No. Inside an async function, a standard try/catch block already captures both synchronous throws and awaited rejections. Promise.try() is designed for .then()-style chains where you need a safe entry point. If you are already using async/await, you likely do not need Promise.try().

The common polyfill using new Promise(resolve => resolve(fn())) is functionally equivalent to the native implementation. It runs fn immediately, catches sync throws via the Promise constructor, and adopts thenables through resolve. It is safe for production use in environments that lack native support.

Promise.resolve(fn()) calls fn outside a promise context, so sync throws become uncaught exceptions. Promise.resolve().then(fn) catches throws but defers execution to a microtask, meaning fn does not run immediately. Promise.try(fn) is the only pattern that both runs fn eagerly and captures sync errors as rejections.

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay