JavaScript

Promise.try & Promise.withResolvers: Cleaner Async (Live Demos)

W
W3Tweaks Team
Frontend Tutorials
Aug 26, 202622 min read
Share:
Promise.try & Promise.withResolvers: Cleaner Async (Live Demos)
Two small ES2026 promise utilities fix two long-standing annoyances. Promise.try runs any function — sync or async — and funnels a synchronous throw into .catch() instead of letting it escape and crash your app (which is exactly the class of bug that shows up in Sentry, Datadog, Rollbar every week). Promise.withResolvers hands you the promise and its resolve/reject in one line, so you can settle it from an event listener without the old closure dance — the killer use case being WebSocket request/response correlation. Live demos, plus TypeScript typing, runtime support (Node 22, Bun, Deno, Vercel Edge, Cloudflare Workers), and the anti-patterns you should skip.

TL;DR

Two small additions to Promise that quietly fix real problems. Promise.try(fn) runs fn immediately inside a try/catch — a synchronous throw becomes a rejection your .catch() can handle, instead of escaping the chain and blowing up the process (this is the exact bug shape Sentry, Datadog, Rollbar, and Bugsnag flag as “unhandled exception in async context” every week). Promise.withResolvers() hands you { promise, resolve, reject } in one destructure, so you can settle a promise from an event listener without the old closure dance.

// The sync-throw fix
await Promise.try(() => JSON.parse(userInput));  // throws → rejects, chain intact

// The deferred fix
const { promise, resolve, reject } = Promise.withResolvers();
socket.once('message', resolve);
return promise;

Watch out: Promise.try(fn) runs fn synchronously — different from Promise.resolve().then(fn) which adds a microtask hop; the two aren’t drop-in replacements. And withResolvers promises leak both the promise and its resolvers if you never settle them, which matters in a long-running WebSocket server or Comlink worker pool. Both are native in Node 22+, Bun 1.1+, Deno 1.40+, and every current browser — nothing to install.

Try it in the live demo — watch a sync throw escape Promise.resolve() but get caught by Promise.try, see the microtask timing side by side, and race a click against a timeout with withResolvers.


So there’s two tiny additions to Promise that landed in the last couple years and both fix problems that annoyed me for like a decade. Neither is fancy. Both are small. And I don’t know why nobody’s told you about them yet.

First one — you wrap a function call in a promise with Promise.resolve(fn()), fn throws synchronously, and the error escapes the entire promise chain. Your .catch() never runs. It’s not a chain error at that point, it’s a plain synchronous exception, and if there’s no top-level handler it crashes the process. I’ve shipped this bug. Twice. Both times it got picked up by Sentry with a stack pointing at completely the wrong place.

Second one — you need to resolve a promise from outside its executor. Event listener, timeout, whatever. You end up hoisting resolve and reject into outer let variables and assigning them from inside the new Promise() executor. Ugly closure dance that everyone rewrites every project. Everyone.

Promise.try and Promise.withResolvers fix each of these. First one runs fn immediately inside a try/catch so a sync throw becomes a rejection — same chain, one .catch() handles everything. Second one gives you { promise, resolve, reject } in a single destructure. Both are native in current browsers and Node — no shim, nothing to install, and honestly if you’d asked me two years ago I’d have told you they’d never ship.

This is the fourth in our Modern JavaScript Features series. Both build on the promise combinators, and the deferred pattern connects to the streaming work over in Array.fromAsync. Rest of the guide: live demos, TypeScript typing (with the ways it can bite), runtime support for actual production deployments, real patterns beyond the toy examples — WebSocket request/response correlation especially — and the anti-patterns that turn withResolvers into a footgun if you’re not careful.


Live Demo

Live DemoOpen in tab

Tab 1: watch a synchronous throw escape Promise.resolve() but get caught by Promise.try. Tab 2: the microtask-timing difference, numbered line by line. Tab 3: resolve a promise from outside with withResolvers — button, timeout, and race.


Part 1: Promise.try

The Problem It Solves

You’ve got a function. Might be sync, might be async. Might throw sync, might reject async. Four possible outcomes basically. You want to handle all four through the same promise chain because writing four separate handlers is dumb. And the obvious approach — the one everybody reaches for first — has a hidden hole in it:

function riskyThrow() {
  throw new Error('sync boom');   // throws synchronously, before returning
}

// ❌ The synchronous throw escapes — .catch() never runs
Promise.resolve(riskyThrow())
  .then(handle)
  .catch(err => console.log('caught:', err));
// Uncaught Error: sync boom  — the throw happens BEFORE Promise.resolve runs

Reason: riskyThrow() is evaluated before Promise.resolve is even called. So the throw is a normal synchronous exception at that point. Your .catch() is attached to a promise that never got created because the function argument threw before construction happened. In a Node process without a top-level error handler that’s an unhandledException — and if you use Sentry, Datadog, Rollbar, Bugsnag, Honeybadger, whichever, they’ll all light up an alert with a stack pointing at wherever the exception bubbled up to. Which is basically never the code you meant to guard. Debugging one of these at 2am is my personal least favorite kind of debugging.

The Fix

Promise.try runs the function inside a try/catch, so a synchronous throw becomes a rejection:

// ✅ The synchronous throw is caught and becomes a rejection
Promise.try(riskyThrow)
  .then(handle)
  .catch(err => console.log('caught:', err));   // caught: Error: sync boom

It handles all four cases uniformly — sync-return, sync-throw, async-resolve, async-reject — through the same .then()/.catch()/.finally() chain:

function doSomething(action) {
  return Promise.try(action)
    .then(result => console.log('result:', result))
    .catch(error => console.log('error:', error.message))
    .finally(() => console.log('done'));
}

doSomething(() => 'sync value');              // result: sync value
doSomething(() => { throw new Error('x'); }); // error: x
doSomething(async () => 'async value');       // result: async value
doSomething(async () => { throw new Error('y'); }); // error: y

This is basically the exact shape you want in a middleware pipeline. Express, Fastify, Hono, Nest.js — handlers regularly wrap user code that might be sync or async. Old-school controller returning a plain value. New async handler doing DB work through Prisma or Drizzle. Request-validation function that throws on bad input, from a Joi or Zod schema. Wrap the call in Promise.try and one .catch() downstream catches all four failure modes. This is the pattern that finally let me delete some pretty ugly error-handling code from a Fastify project earlier this year.

Why Not Promise.resolve().then()?

You might reach for Promise.resolve().then(fn) to defer the call into a promise. It does catch synchronous throws — but it changes the timing:

// Promise.resolve().then(fn) — fn runs LATER, in a microtask
console.log('1');
Promise.resolve().then(() => console.log('3'));   // deferred
console.log('2');
// Logs: 1, 2, 3

// Promise.try(fn) — fn runs NOW, synchronously
console.log('1');
Promise.try(() => console.log('2'));   // immediate
console.log('3');
// Logs: 1, 2, 3 — but '2' ran synchronously, before '3'

Promise.resolve().then(fn) always defers fn to the next microtask. Adds an async hop no matter what. Promise.try(fn) runs fn right now, synchronously, and only becomes async if fn itself returns a promise.

For sync work that microtask delay is basically pointless overhead. Which matters in a hot request path — Fastify at 10k req/s, or an AI-SDK tool-call handler firing thousands of these per second. Little things add up. Demo’s second tab numbers every log line by execution order so you can actually see the difference play out.

Passing Arguments

Like setTimeout, Promise.try forwards extra arguments to the callback, so you can skip the wrapper closure:

// Instead of an extra arrow closure:
Promise.try(() => func(arg1, arg2));

// Pass args directly — no closure allocated:
Promise.try(func, arg1, arg2);

Small thing. The extra closure allocation adds up in a hot loop though, and honestly it just reads cleaner.

When to Use It

Rule of thumb: reach for Promise.try when you’re calling a function whose result you want as a promise, and you don’t actually know if the function is sync or async, or whether it can throw synchronously.

Classic cases where I use it:

  • Middleware and pipeline runners. Express, Fastify, Hono, Nest.js, tRPC procedures — anything wrapping user handlers.
  • Wrapper utilities. Logging middleware. Retry. Timeout. Circuit breakers.
  • Error boundaries at request scope — the stuff Sentry / Datadog will flag if you forget them.
  • Generic async libraries. Any time a user hands your library a callback of unknown shape.
  • AI-SDK tool-call handlers. OpenAI, Anthropic, Vercel AI SDK — the tool-calling flow passes user code that your library then invokes, and a sync throw in there can tear down the whole streaming response. Wrap the call in Promise.try, one less way for that to blow up.

Don’t use it if you already know a function is async and returns a promise. Just call the function. That’s already what you want.


Part 2: Promise.withResolvers

The Problem It Solves

Sometimes the thing that resolves your promise lives outside its executor. DOM event. Timeout. WebSocket message. Web Worker postMessage reply. The classic pattern hoists resolvers into outer variables, which sucks:

// ❌ The old way — hoist resolve/reject out of the executor
let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

// ...later, from an event listener:
button.addEventListener('click', () => resolve('clicked'));

Works fine. But the two-step dance — declare the vars, then assign them from inside the executor — is boilerplate you write literally every project. I bet you’ve written that snippet 40 times.

If you were around in older JS days you probably remember this being called a “Deferred”. jQuery had $.Deferred(). Q had Q.defer(). Bluebird had Promise.defer() and then later removed it because they decided it was an anti-pattern in most cases (which was maybe true, kinda? Depends on the case). Promise.withResolvers is basically the sanctioned native version of that same idea, minus the historical baggage. It took the spec people like eight years to admit we actually needed this.

The Fix

Promise.withResolvers() returns the promise and both functions in one object, so you destructure them in a single line:

// ✅ The new way — one line
const { promise, resolve, reject } = Promise.withResolvers();

button.addEventListener('click', () => resolve('clicked'));

await promise;   // resolves when the button is clicked

Same behaviour. No closure. And the intent is right there when you read it.

The Killer Use Case: WebSocket Request/Response Correlation

This is where withResolvers really earns its keep imo. WebSockets are duplex — you send, you receive, but the two are NOT correlated by the transport. If your app wants request/response semantics over the socket (send a query, await the specific reply to that query) you have to correlate requests to responses yourself. Socket.io, ws, Ably, Pusher, PartyKit, all of them — every real-time layer eventually hits this exact pattern:

class RpcSocket {
  constructor(socket) {
    this.socket = socket;
    this.pending = new Map();       // requestId → { resolve, reject }
    this.socket.on('message', (raw) => {
      const { id, result, error } = JSON.parse(raw);
      const deferred = this.pending.get(id);
      if (!deferred) return;
      this.pending.delete(id);
      error ? deferred.reject(new Error(error)) : deferred.resolve(result);
    });
  }

  request(payload) {
    const id = crypto.randomUUID();
    const { promise, resolve, reject } = Promise.withResolvers();
    this.pending.set(id, { resolve, reject });
    this.socket.send(JSON.stringify({ id, ...payload }));
    return promise;
  }
}

// Usage — feels like a normal await
const answer = await rpc.request({ type: 'query', sql: 'SELECT ...' });

That’s the whole pattern. Before withResolvers the request() method needed the hoist-resolvers dance inside, and most people (me included, in like three separate projects) gave up and reached for a wrapper library instead of writing it. Same shape works for Web Worker RPC with Comlink or Threads.js. Convex/Liveblocks real-time subscriptions. Bridging Firebase’s callback-heavy older SDK surfaces into promises.

Two things you MUST add though or this will bite you in production: a timeout, and cleanup on socket close. The pending map leaks memory forever until every request settles. And a disconnected socket will never settle any of its outstanding requests, so without cleanup you leak both the promise AND both resolver closures for every in-flight request that was in progress when the socket died. Ask me how I know.

Also worth combining with AbortController if the caller might want to cancel — the timeout section later touches on that.

More Real Patterns

Resolve on a DOM event (the toy example, but genuinely useful for onboarding flows and Playwright/Cypress test helpers):

function waitForClick(button) {
  const { promise, resolve } = Promise.withResolvers();
  button.addEventListener('click', () => resolve(), { once: true });
  return promise;
}

await waitForClick(startButton);   // pauses until the click

A cancellable timeout — the pattern almost every retry library uses internally:

function createTimeout(ms) {
  const { promise, reject } = Promise.withResolvers();
  const timer = setTimeout(() => reject(new Error('Timed out')), ms);
  return { promise, clear: () => clearTimeout(timer) };
}

Awaiting first render / manual trigger in tests. Vitest, Jest, Playwright, and Cypress all give you async helpers, but sometimes you need to pause a test until some app-side condition fires. withResolvers gives you the trigger:

// In a Playwright test, coordinate with app-side code exposed via window
test('feature is ready', async ({ page }) => {
  await page.evaluate(() => {
    window.__ready = Promise.withResolvers();
    window.markReady = () => window.__ready.resolve();
  });
  await triggerAppLoad(page);
  await page.evaluate(() => window.__ready.promise);
});

Convert an event-based stream to promises — the resolvers can be reassigned each batch, attaching the listener only once:

function nextChunk(stream) {
  let { promise, resolve } = Promise.withResolvers();
  stream.on('data', chunk => {
    resolve(chunk);
    ({ promise, resolve } = Promise.withResolvers());   // fresh promise for next chunk
  });
  return () => promise;   // caller calls to await the next chunk
}

The demo’s third tab wires withResolvers to a real button, a timeout, and a Promise.race between them, all resolved from outside the executor.

When NOT to Use withResolvers (Anti-Patterns)

withResolvers is a niche tool. If you can settle a promise inside the executor, just use new Promise(). Spreading resolve/reject around your codebase is how legacy jQuery-Deferred codebases became unmaintainable and I’ve inherited two of those in my career, would not recommend the experience.

Signs you’re using it wrong:

  • Passing resolve/reject more than one function deep. If the resolver leaves the file it was created in, something’s off. Just wrap the promise-producing code in a normal async function instead. Honestly.
  • Using it inside a React component render. State setters exist for a reason. Zustand, Redux Toolkit, Jotai, Pinia — every state manager has async action patterns that don’t need this. Suspense-adjacent tricks want a real cache library — TanStack Query, SWR, Apollo. Not a hand-rolled deferred.
  • Never actually resolving some paths. Straight up memory leak. Promise plus both resolver closures stay pinned forever. Long-running Node processes bloat slowly — Fastify server, Comlink worker pool, Socket.io namespace. Days later you’ll wonder why memory keeps climbing.
  • Building a general-purpose event emitter with it. Use EventTarget or Node’s EventEmitter. Or async iterators — see the Array.fromAsync tutorial for that path.

Rule of thumb — reach for withResolvers when the trigger to resolve is genuinely external. Event, callback, network reply. Not because “it’s cleaner than new Promise”. For most code, it really isn’t.

A Memory Note

Repeating myself but it’s the #1 mistake so it’s worth repeating. The promise and its resolver functions stay in memory until the promise settles. In long-running code — event system, resource pool, WebSocket RPC map — make sure every deferred promise eventually resolves or rejects. Or you leak both the promise AND its resolver closures.

Add a timeout + cleanup for the RPC-map pattern above. And in general, prefer letting a promise reject on a timeout over letting it stay pending forever. Pending forever is a memory leak with extra steps.


TypeScript Typing

TypeScript 5.4+ ships built-in types for both. The signatures you’ll actually use:

// Promise.try — overloaded to preserve the return type of fn
interface PromiseConstructor {
  try<T, U extends unknown[]>(
    fn: (...args: U) => T | PromiseLike<T>,
    ...args: U
  ): Promise<Awaited<T>>;
}

// Promise.withResolvers — parameterized on the resolved value type
interface PromiseConstructor {
  withResolvers<T = unknown>(): {
    promise: Promise<T>;
    resolve: (value: T | PromiseLike<T>) => void;
    reject: (reason?: unknown) => void;
  };
}

Two tsconfig.json gotchas that got me. First: you need "target": "ES2024" or newer (or "lib" explicit) — otherwise you get Property 'try' does not exist on type 'PromiseConstructor' which is a fun error message to encounter for the first time. Second: for Node backends, @types/node@22 or newer. Older versions don’t augment the global at all.

If your bundler chain — esbuild, swc, tsup, Vite — targets an older syntax level, the type is fine but the emitted runtime call may not exist. Check your target matches your deployment: Node 22+, Bun 1.1+, Deno 1.40+, Chrome 128+, Safari 18+, Firefox 128+.

For withResolvers specifically: always pass the generic when the return type isn’t inferable from immediate usage. Promise.withResolvers<string>() — worth the extra characters. Or you get unknown propagating downstream and it’s annoying to track back where it started.


Runtime Support Across Serverless & Edge

The question everybody actually asks: where can I ship this today. Here’s the actual matrix.

RuntimeSupportNotes
Node.js 22+ LTS✅ Both nativewithResolvers since Node 22.0; Promise.try since Node 22.6
Bun 1.1+✅ Both native
Deno 1.40+✅ Both native
Vercel Serverless / Edge FunctionsNode 22 runtime or V8 isolate — both cover it
Cloudflare WorkersV8 isolate, ships modern language features early
Deno Deploy
Netlify FunctionsNode 22 runtime
AWS Lambda✅ (Node 22)Node 20 also has withResolvers
Chrome / Edge128+ for Promise.try, 121+ for withResolvers
FirefoxSame window as Chrome
Safari18+ for both

For legacy browser matrices core-js polyfills both, but honestly for any fresh backend you’re spinning up today, you don’t need any polyfill. Ship it.


Key Takeaways

  • Promise.try(fn) runs fn immediately inside a try/catch, turning a synchronous throw into a rejection your .catch() can handle — the exact bug shape Sentry, Datadog, Rollbar, Bugsnag flag as “unhandled exception in async context”
  • Promise.resolve(fn()) evaluates fn() before the promise exists, so a synchronous throw escapes the chain and can crash the process
  • Promise.try handles all four cases — sync-return, sync-throw, async-resolve, async-reject — through one .then()/.catch()/.finally() chain; ideal for middleware runners (Express, Fastify, Hono, Nest.js, tRPC) and AI-SDK tool-call handlers
  • Unlike Promise.resolve().then(fn), Promise.try(fn) runs fn synchronously (no extra microtask hop) and only goes async if fn returns a promise
  • Promise.try(fn, a, b) forwards arguments to fn, avoiding an extra wrapper closure (small win but adds up in hot paths)
  • Promise.withResolvers() returns { promise, resolve, reject } in one destructure, replacing the hoist-resolvers-out-of-the-executor boilerplate (basically a sanctioned, native version of the old jQuery $.Deferred / Q defer() / Bluebird Promise.defer() pattern)
  • The killer use case: WebSocket request/response correlation — pending Map keyed on request ID, each entry storing the resolvers, message handler dispatches to the right one. Same pattern for Web Worker RPC (Comlink, Threads.js), Convex/Liveblocks/Firebase bridges, and Playwright/Cypress test coordination
  • Anti-patterns: passing resolvers more than one function deep, using inside React component render, never resolving some paths (memory leak), building a general event emitter (use EventTarget or async iterators instead)
  • Deferred promises and their resolvers stay in memory until settled — always resolve or reject them, and add timeout+cleanup on maps that hold them
  • TypeScript: "target": "ES2024" in tsconfig, @types/node@22+ for Node, always parameterize Promise.withResolvers<T>() explicitly
  • Runtime support: Node 22+, Bun 1.1+, Deno 1.40+, all current browsers (Chrome 128+, Safari 18+, Firefox 128+), and every major serverless/edge platform — no polyfill needed for a fresh deployment

FAQ

What does Promise.try do?

Promise.try(fn) calls fn immediately and wraps the outcome in a promise: a returned value becomes a fulfilled promise, a returned promise is adopted, and — crucially — a synchronous throw becomes a rejected promise instead of an uncaught exception. This lets you handle synchronous and asynchronous functions, and synchronous throws and async rejections, all through one .then()/.catch()/.finally() chain. It shipped in the modern ECMAScript spec and is native in current browsers, Node 22.6+, Bun 1.1+, and Deno 1.40+.

Why use Promise.try instead of Promise.resolve()?

Because Promise.resolve(fn()) evaluates fn() before Promise.resolve runs, so if fn throws synchronously the error escapes and is never turned into a rejection — your .catch() cannot see it, and the exception hits your process-level error handler (which is what Sentry, Datadog, or Rollbar then flags with the wrong stack trace). Promise.try(fn) passes the function itself (not its result) and runs it inside a try/catch, so a synchronous throw becomes a rejection. It’s the safe, concise way to lift a possibly-throwing function into a promise chain.

What is the difference between Promise.try and Promise.resolve().then()?

Both catch synchronous throws, but they differ in timing. Promise.resolve().then(fn) always defers fn to the next microtask, adding an asynchronous hop even when fn is synchronous. Promise.try(fn) runs fn immediately and synchronously, only becoming asynchronous if fn itself returns a promise. So Promise.try avoids an unnecessary microtask delay for synchronous work while still providing unified error handling — which matters in hot request paths inside Fastify, Hono, Nest.js, or AI-SDK tool-call loops where you may run thousands of these per second.

What does Promise.withResolvers do?

Promise.withResolvers() creates a new promise and returns it together with its resolve and reject functions in a single object: const { promise, resolve, reject } = Promise.withResolvers(). This replaces the older pattern of declaring resolve and reject variables and assigning them inside a new Promise() executor. It is useful whenever a promise must be settled from outside its executor — from an event listener, a timeout, a WebSocket message, or a Web Worker postMessage reply.

When should I use Promise.withResolvers?

Use it whenever the logic that resolves or rejects a promise lives outside the promise’s own executor function. Common cases: WebSocket request/response correlation (a pending Map keyed on request ID, each entry storing the resolvers — the killer use case for real-time apps built on Socket.io, ws, Ably, Pusher, or PartyKit), Web Worker RPC with Comlink or Threads.js, DOM-event await helpers for Playwright/Cypress tests, cancellable timeouts, and bridging callback-heavy legacy APIs to promises. If you can resolve the promise entirely within the executor, plain new Promise() is simpler.

What are the anti-patterns with Promise.withResolvers?

Four to watch. First — don’t pass resolve/reject more than one function deep; if the resolver leaves the file it was created in, wrap the promise-producing code in a normal async function instead. Second — don’t use it inside a React component render; state managers like Zustand, Redux Toolkit, Jotai, or Pinia have async action patterns for that, and data-fetching should go through TanStack Query, SWR, or Apollo. Third — always resolve or reject every path, otherwise the promise and both resolver closures stay pinned in memory (real leak in long-running Node servers). Fourth — don’t build a general event emitter with it; use EventTarget, Node’s EventEmitter, or async iterators.

Are Promise.try and Promise.withResolvers supported in browsers and Node?

Yes. Native in Node.js 22+ (withResolvers since 22.0, Promise.try since 22.6), Bun 1.1+, Deno 1.40+, and current browsers (Chrome 128+, Safari 18+, Firefox 128+). Every major serverless/edge platform ships them: Vercel Serverless and Edge Functions, Cloudflare Workers, Deno Deploy, Netlify Functions, AWS Lambda (Node 22 runtime), Fastly Compute@Edge. For legacy-browser matrices core-js polyfills both — no shim needed for any fresh backend deployment.

How do I build a WebSocket RPC layer with Promise.withResolvers?

Keep a Map<requestId, { resolve, reject }> on the socket wrapper. When you send a request, generate an ID, call Promise.withResolvers(), store the resolvers keyed on the ID, and return the promise. Attach a single message handler to the socket that parses the incoming payload, looks up the ID in the map, deletes the entry, and calls resolve(result) or reject(error). Add a timeout that rejects and deletes the entry after N seconds, and a socket-close handler that rejects every pending entry — otherwise you leak both the promise and its resolvers for every request that never got a reply. This is the correlation layer under Socket.io RPC, Comlink for Web Workers, and the client-side of most tRPC websocket-transport setups.