DEV Community

Cover image for Promise.withResolvers: The Pattern That Killed Half Your Deferred Helpers
Gabriel Anhaia
Gabriel Anhaia

Posted on

Promise.withResolvers: The Pattern That Killed Half Your Deferred Helpers


Open any non-trivial TypeScript codebase and search for the
string createDeferred. You will probably find one. Maybe
two. They are usually about ten lines long, written by the
same engineer, copied between repos, and tucked into a file
called utils/promise.ts next to a helper for sleep and a
helper for withTimeout. They all do the same thing: build
a Promise, capture resolve and reject from inside the
executor, and hand the trio back to the caller. By the third
repo most engineers carry a createDeferred helper around
like a houseplant.

That helper has been obsolete since 2024. It is called
Promise.withResolvers(), it shipped in Node 22, in current
Bun and Deno, and it has been Baseline across
browsers

since March of that year. It is a static method on the
Promise global. It returns the same three things your
hand-rolled helper returned. You can delete the helper.

The thing the executor cannot do

The reason createDeferred exists is a small mismatch in
the Promise constructor's shape. You pass it an executor.
The executor receives resolve and reject. The executor
runs synchronously, before the constructor returns. If the
event you are waiting for happens outside that executor's
synchronous prelude, you have to capture resolve and
reject to a variable in the enclosing scope.

The hand-rolled version, in five lines.

function createDeferred<T>() {
  let resolve!: (v: T) => void;
  let reject!: (e: unknown) => void;
  const promise = new Promise<T>((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}
Enter fullscreen mode Exit fullscreen mode

The non-null assertions on resolve and reject are the
tell. TypeScript cannot prove the executor ran before the
return statement, even though it always does. You write the
! once, the team copies the pattern, and now every codebase
in the company has a slightly different version of the same
helper.

The standardised version is one line.

const { promise, resolve, reject } = Promise.withResolvers<T>();
Enter fullscreen mode Exit fullscreen mode

Same three things, no helper, no assertion, no copy. The
generic flows through. resolve is (v: T) => void. The
reject signature in lib.es2024.promise.d.ts is typed as
(reason?: any) => void, which is what you would tighten in
your own Pending<T> shape. The promise is Promise<T>. You
import nothing and you delete the helper file.

If you want a polyfill for older runtimes, write the
hand-rolled createDeferred once and call it withResolvers,
or pull in the core-js polyfill that MDN's polyfill section
points at. On Node 22+, Bun, Deno, and any browser shipping
in the last two years, you do not need it.

Pattern 1: a request/response correlator over a socket

This is the shape that pays for the method on its own. You
have a single connection: a WebSocket, a BroadcastChannel,
a MessagePort, an MQTT client, or anything else that
interleaves requests and responses on one wire. Each outbound
message carries an id. Each inbound message carries the id it
is responding to. You need to expose a normal-looking
request(...) function that returns a Promise resolving
when the matching response arrives.

The state lives in a Map from id to a pending settler. With
withResolvers, the settler is the result of the call.

type Pending<T> = {
  resolve: (v: T) => void;
  reject: (reason?: unknown) => void;
};

type Reply<Res> =
  | { id: string; data: Res }
  | { id: string; error: string };

class Correlator<Req, Res> {
  private pending = new Map<string, Pending<Res>>();
  private nextId = 0;

  constructor(private send: (msg: Req & { id: string }) => void) {}

  request(payload: Req, timeoutMs = 5_000): Promise<Res> {
    const id = String(++this.nextId);
    const { promise, resolve, reject } =
      Promise.withResolvers<Res>();
    this.pending.set(id, { resolve, reject });

    const timer = setTimeout(() => {
      if (this.pending.delete(id)) {
        reject(new Error(`request ${id} timed out`));
      }
    }, timeoutMs);

    promise.finally(() => clearTimeout(timer));

    this.send({ ...payload, id });
    return promise;
  }

  onMessage(msg: Reply<Res>) {
    const slot = this.pending.get(msg.id);
    if (!slot) return;
    this.pending.delete(msg.id);
    if ("error" in msg) slot.reject(new Error(msg.error));
    else slot.resolve(msg.data);
  }
}
Enter fullscreen mode Exit fullscreen mode

The hand-rolled version of this looks identical with one
extra helper at the top of the file and two non-null
assertions inside it. The thing worth noticing is that the
shape of Pending<T> matches what withResolvers returns
almost exactly. The whole point of the method is that this
data structure used to be ad-hoc per codebase and is now
standard.

If you have ever debugged a WebSocket client where the wire
gets out of order under load and one request gets resolved
with another's payload, that bug lives entirely in the
Map-eviction logic above. withResolvers does not fix it
for you. It just stops the helper that wraps the Map from
being one of the things you have to read past on the way to
the bug.

Pattern 2: a cancellable queue worker

Job queues that yield work to multiple consumers want a
shape where take() returns a Promise<Job> that resolves
when a job is enqueued. The interesting bit is that the
settlement of that promise is triggered from enqueue, on a
totally different call stack, possibly seconds or minutes
later. That is exactly the shape withResolvers was built
for.

type Waiter<T> = {
  resolve: (v: T) => void;
  reject: (reason?: unknown) => void;
};

class Queue<T> {
  private items: T[] = [];
  private waiters: Waiter<T>[] = [];
  private closed = false;

  enqueue(item: T): void {
    if (this.closed) throw new Error("queue closed");
    const w = this.waiters.shift();
    if (w) w.resolve(item);
    else this.items.push(item);
  }

  take(signal?: AbortSignal): Promise<T> {
    if (this.closed) return Promise.reject(new Error("closed"));
    const item = this.items.shift();
    if (item !== undefined) return Promise.resolve(item);

    const { promise, resolve, reject } =
      Promise.withResolvers<T>();
    const waiter: Waiter<T> = { resolve, reject };
    this.waiters.push(waiter);

    if (signal) {
      const onAbort = () => {
        const idx = this.waiters.indexOf(waiter);
        if (idx >= 0) this.waiters.splice(idx, 1);
        reject(signal.reason ?? new Error("aborted"));
      };
      if (signal.aborted) onAbort();
      else signal.addEventListener("abort", onAbort, {
        once: true,
      });
    }

    return promise;
  }

  close(): void {
    this.closed = true;
    const err = new Error("queue closed");
    for (const w of this.waiters) w.reject(err);
    this.waiters = [];
  }
}
Enter fullscreen mode Exit fullscreen mode

The settlement happens from enqueue, from close, or from
the AbortSignal callback. Three different code paths, all
holding a reference to the same resolve/reject pair. The
old shape with a hand-rolled deferred works the same way; the
difference is that the standard method ages out the
utils/deferred.ts file and reads the same on every
reviewer's screen.

A consumer loop on top of this is twenty lines of for await
and an AbortController. The point is that the queue itself
does not need to know about consumers, because each
take() call gets its own promise, settled by whichever side
gets to it first.

Pattern 3: a test fixture you settle from outside

This is the place withResolvers reads best, because the
whole point of the test is to control timing from the outside.

You are testing code that listens for an event, an idle
callback, a MessageEvent, a click. The fake event source
needs to be a Promise that the test can settle on demand.

import { test, expect } from "vitest";

function makeFakeFetch<T>() {
  const { promise, resolve, reject } =
    Promise.withResolvers<T>();
  const fakeFetch = (_url: string): Promise<T> => promise;
  return { fakeFetch, resolve, reject };
}

test("renders skeleton until response arrives", async () => {
  const { fakeFetch, resolve } = makeFakeFetch<{ items: string[] }>();
  const view = renderUserList({ fetch: fakeFetch });

  expect(view.html()).toContain("Loading");

  resolve({ items: ["alice", "bob"] });
  await view.flush();

  expect(view.html()).toContain("alice");
  expect(view.html()).toContain("bob");
});
Enter fullscreen mode Exit fullscreen mode

The fixture function reads as plain English. There is no
new Promise constructor, no captured-from-executor pattern,
no helper import path the reader has to follow. The test gets
a fake fetch and a settle button, settles when it wants to
assert on the post-loading state, and asserts.

The same shape works for IntersectionObserver, WebSocket
mocks, animation frames, and any DOM event source you want to
fake. Build a small factory that wraps withResolvers, and
each test gets a tiny stage where it controls timing
explicitly.

The rough edge: who owns the resolver

The thing that bites every codebase adopting this pattern is
the same thing that bit the hand-rolled createDeferred
codebase before it. Holding a reference to resolve outside
the executor makes it easier to forget to settle. The promise
consumer is now waiting on a settlement that no code path
will ever deliver.

The bug has three flavours.

The first is the request that times out at the wrong layer.
Code calls correlator.request(...), the network call drops,
nothing in the correlator notices, and the Map entry sits
forever holding the resolver. The promise consumer's await
hangs until something else times out. The fix is the
setTimeout in pattern 1. Every withResolvers call needs
a timeout owner, even if the timeout is "the parent
AbortSignal". If no code path holds responsibility for
settling on failure, you have a leak.

Then there is the queue that closes without notifying
waiters. Pattern 2's close() is what you would forget if
you wrote it the first time. You add a close() method, you
flip a flag, and you do not iterate the waiters array.
Every consumer that called take() before close is now
hanging. The fix is to walk the array and reject everyone,
a few lines you cannot afford to skip. The cost of forgetting
is unbounded.

Last, the test that resolves on the happy path and forgets to
do anything in the cleanup. The promise was
captured by the production code under test. The test ends.
Vitest moves on. Nothing in the production code crashes,
because the promise never settles and nothing awaits it
anymore. But if the production code does something on a
finally, that work never runs. The fix is a
reject(new Error("test cleanup")) in afterEach, or
a fixture that auto-rejects on test end.

The shape of all three bugs is the same: a captured
resolve/reject pair where ownership is unclear. Pin
ownership to one code path: the timeout, the close, or the
cleanup hook. That keeps the rough edge from cutting.

A reasonable rule: every place you call withResolvers,
write the line that settles it on failure on the same screen.
If you cannot, refactor until you can. The captured resolver
is a resource. Treat it the way you treat a file handle.

Where the standard pulls its weight

Promise.withResolvers is not a feature that earns a blog
post on its own technical novelty. It earns one because it
deletes a file from every TypeScript codebase that has been
running for more than two years, and because it standardises
the shape that every async-correlation pattern was already
using.

Once withResolvers is in your toolbox, the next pieces are
adjacent and quietly useful: AbortSignal.timeout for the
timeout half of pattern 1, AbortSignal.any for combining
signals across cancellation sources, and the using
declaration (the Explicit Resource Management proposal,
supported in TypeScript 5.2+) for tying resolver ownership
to a lexical scope. None of those are headline features
either. All of them remove a small helper from your repo and
nudge the language a step closer to the shape you were
already writing.


If this was useful

Promise.withResolvers is one of the small ECMAScript wins
that TypeScript Essentials threads through the async chapter:
promises, AbortSignal, the bits of the type system that
model Pending<T> cleanly, and the tooling that catches
captured-resolver bugs before they ship. If the three patterns
above mapped to code you already have running, that is the book.

For readers who want to go deeper into the type system, The
TypeScript Type System
picks up where Essentials lands:
infer patterns, conditional types, branded types, and the
DeepReadonly-shaped helpers that compose with patterns like
this one. TypeScript in Production is the
tooling/build/library-author layer.

The five-book set:

  • TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
  • The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
  • Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
  • PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
  • TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471

Books 1 and 2 are the core path. Books 3 and 4 substitute for readers coming from JVM or PHP. Book 5 is for shipping TS at work.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)