Home›Journal›This post

Promise.withResolvers(): Own Async Settlement

Model one-way settlement, make the first terminal event win, preserve abort reasons, detach every resource, and export an ordered lifecycle receipt.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

Promise.withResolvers changes where resolve and reject live, not the semantics of Promise settlement. Because an external source owns completion, the adapter takes on stricter lifecycle obligations for cancellation, cleanup, stale callbacks, and ordering.

Understand the Promise.withResolvers capability

Promise.withResolvers changes where resolve and reject live; it does not change Promise settlement semantics. When an external event source owns settlement, that convenience also moves lifecycle obligations into your adapter.

The API returns an object containing a promise plus its resolving functions. The ECMAScript Promise.withResolvers specification defines that capability. Calling resolve or reject after the first settlement does not change the promise's observable state, just as with the constructor form.

The difference is structural. A constructor keeps the resolving functions inside an executor callback. Promise.withResolvers makes them ordinary local values that can be registered with event listeners, stream callbacks, or a request table. That can clarify an external promise settlement adapter, but it can also make ownership easier to lose.

Use a capability only when a source outside the current call stack determines completion. If the operation is naturally expressed as await a fetch, transform data, and return, an async function remains clearer. This tutorial focuses on one bounded adapter with explicit terminal transitions and cleanup.

For a larger cancellation pipeline, building a cancellable fetch pipeline shows how one signal crosses request stages. Here the narrower problem is one promise resolve reject capability and every callback that could try to settle it.

Promise capability ownership mapThe caller receives the promise and supplies an optional signal, while the adapter privately owns resolve, reject, generation, timer, listeners, and cleanup.SEPARATE THE CAPABILITY · KEEP ONE OWNERCALLERpromiseAbortSignalawait / catchADAPTERresolve · rejectstate · generationlisteners · timercleanup · receiptSOURCEdataerrorcloseThe source delivers events; only the adapter may commit a terminal transition.
Promise capability ownership map. External settlement stays comprehensible when resolve and reject remain private to one finite-lifetime adapter.
Caller
Owns awaiting the promise and may provide a cancellation signal.
Adapter
Owns resolving functions, state, generation, resources, cleanup, and evidence.
Source
Delivers data, error, and close events but cannot overwrite a terminal receipt.

Reading rule: labels, order, values, shapes, and line styles carry the conclusion; color is supplementary.

Choose an external-settlement use case

Consider an event source that emits data, error, and close while the caller may abort or time out. The adapter should fulfill on the first valid data event, reject on source error, reject with the abort reason, or reject on deadline. It must detach every listener and clear its timer after any terminal outcome.

This is a good Promise.withResolvers case because event registration happens after the capability exists, and several callbacks need controlled access to the same terminal operation. Put those callbacks inside one factory; do not return resolve and reject to unrelated consumers.

Define the contract before code: pending may transition once to fulfilled, rejected, aborted, or timed-out. Later terminal events are recorded as ignored. A callback from an older operation generation is stale and cannot commit. Cleanup runs exactly once regardless of which event wins.

The first figure shows ownership. The caller owns the promise and optional AbortSignal. The adapter owns resolve, reject, generation, listeners, timer, and receipt. The source owns only event delivery. This boundary prevents a common design error in which an emitter is expected to clean up a timer it did not create.

Use explicit resource management in JavaScript as a parallel: acquisition and cleanup need the same lexical owner. Promise.withResolvers is useful when that owner remains obvious.

Model the JavaScript async lifecycle

Represent lifecycle as data, not booleans scattered through closures. A record contains state, terminal kind, reason or value summary, generation, settlement sequence, ignored-event count, listener count, timer status, and ordered timeline entries. State begins pending and never changes after the first terminal transition.

The transition function checks three things: the event belongs to the current generation, state is still pending, and the event kind is supported. It then captures the first reason, marks the terminal state, performs cleanup, invokes the correct resolver, and appends a settlement microtask marker.

The state table in figure two includes duplicate resolve, reject after resolve, abort after timeout, and a stale callback. Those events do not overwrite the receipt. Ignoring them is different from hiding them; their counts help tests prove that races were exercised.

The lab uses at most 64 scripted events over a virtual timeline no longer than 5,000 milliseconds. Virtual time makes fixtures deterministic and fast. It does not claim to reproduce every browser timing edge.

In a multi-tool system, structured concurrency for AI agent tools expands the same principle: child work should have bounded ownership and cancellation. This Promise.withResolvers adapter is the smallest useful version of that discipline.

Make the first terminal event win

Promise semantics already ignore later resolver calls, but your surrounding receipt, state, UI, and cache can still be overwritten by careless callbacks. Guard the entire terminal commit, not only resolve or reject. The first accepted terminal event chooses state, reason, cleanup, and exported evidence together.

Use a settle function that returns accepted or ignored. When pending, it copies the terminal payload into a safe summary, flips state, records the winning event, cleans resources, then calls resolve or reject. When terminal, it increments an ignored count and preserves the original reason.

The constructor adapter and Promise.withResolvers adapter should share only the lifecycle controller and validation, not the capability creation code. Their externally visible outcomes must agree across resolve, reject, abort, timeout, duplicate, and stale-event fixtures. This is equivalence evidence, not a speed comparison.

A named mutant lets second settlement overwrite the receipt while the underlying promise remains settled. Another allows a stale callback from generation one to commit into generation two. Both reveal why checking the final promise alone is inadequate.

Return immutable or copied receipt data to observers. Do not expose internal arrays that a caller can mutate after completion. Promise.withResolvers reduces constructor ceremony; it does not remove the need for a coherent state machine around the capability.

One-way async lifecycle transition table

Pending accepts one terminal event; duplicate, post-terminal, and stale events are retained as ignored evidence without changing the winner.

CurrentEventNextReceipt action
Pendingresolve(value)FulfilledPreserve value; cleanup once
Pendingreject(error)RejectedPreserve reason; cleanup once
Pendingabort(reason)AbortedPreserve abort reason; cleanup once
PendingdeadlineTimed outPreserve timeout; cleanup once
Terminalany terminal eventUnchangedCount ignored; never overwrite
Anyold generation callbackUnchangedCount stale; never commit
The state table makes promise settlement and surrounding application state follow the same first-wins rule.
  • There is exactly one transition away from pending.
  • The first terminal kind and reason remain immutable.
  • Every later terminal event is counted as ignored.
  • Every old-generation event is counted as stale.
  • Listener and timer counts must reach zero after the winner.

Reading rule: the pass or fail words and numeric values carry the conclusion; color is supplementary.

Compose AbortSignal cancellation and timeout

Cancellation has two entry paths: the caller's signal and the adapter's deadline. If the signal is already aborted, settle immediately with its reason and do not attach the source. Otherwise register one abort listener and retain the exact function reference needed for removal.

The WHATWG DOM AbortSignal definition supplies the cancellation vocabulary, including an abort reason. Preserve that reason rather than replacing it with a generic message. The lab's dropped-reason mutant must fail.

A timeout is another terminal contender, not a special side channel. Create at most one timer, record its due time, and clear it in the same cleanup path used by resolve, reject, and abort. A timeout-survives-cleanup mutant fires later and must be visible as a leaked resource or ignored event.

Decide precedence by observed order. An abort scheduled before the deadline wins; a source value first wins; events with an equal virtual timestamp follow the script's explicit sequence. Document that fixture convention because real task ordering depends on how the actual sources queue callbacks.

AbortSignal cancellation does not forcibly stop arbitrary work. The adapter asks the source to unsubscribe or cancel when such an operation exists, then records what happened. Truthful cleanup evidence distinguishes listener detachment from guaranteed remote cancellation.

Detach every listener

Cleanup is part of settlement, not an optional finally block someone might forget to await. Keep a list of exact remover functions as resources are attached. On the winning terminal event, clear the timer, remove source listeners, remove the abort listener, invalidate the generation, and mark cleanup complete.

Make cleanup idempotent so defensive calls are harmless. Count attached and removed listeners; both totals must match at the end. Count timers created and cleared; no live timer may remain. If source cancellation throws, capture the cleanup error without allowing a second settlement to replace the original terminal reason.

The listener-leak mutant intentionally skips one remover. It may still produce the expected promise value, which is why resource counters are first-class assertions. Long-lived products often suffer from stale UI commits or retained closures rather than incorrect initial settlement.

For a parser that also spans callbacks and cancellation, building a streaming NDJSON parser demonstrates why partial buffers, readers, and abort paths need explicit ownership. Promise.withResolvers should make that ownership easier to read.

Expose a useful live status for users: pending with attached-resource counts, then a concise terminal outcome and cleanup result. Do not announce every simulated event through an aria-live region; update a summarized status to avoid noisy assistive output.

Task, microtask, cleanup, and stale-callback timelineA source task settles and cleans synchronously, its promise reaction runs at the microtask checkpoint, and a later stale task cannot commit.ORDER IS EVIDENCE · DURATION IS NOT1TASK START2SETTLE + CLEAN3TASK END4MICROTASK5STALE TASKpromise reaction waits for checkpointgeneration mismatch → ignored, no commit
Task, microtask, cleanup, and stale-callback timeline. Exact event labels test ordering without pretending a virtual timeline is a browser performance measurement.
  1. The source callback begins as a task and calls the guarded settle function.
  2. Settlement commits the winner and cleanup removes listeners and timer synchronously.
  3. The source callback returns; the current task ends.
  4. The promise reaction runs at the microtask checkpoint.
  5. A later task delivers an old-generation callback, which is recorded and ignored.

Reading rule: labels, order, values, shapes, and line styles carry the conclusion; color is supplementary.

Test task, microtask, and stale ordering

Promise reactions run as jobs commonly described as microtasks, after the current callback returns and before a later task is processed at the relevant checkpoint. The WHATWG HTML event-loop section is the platform source for task queues and microtask checkpoints.

The third figure shows a source task calling settle, synchronous cleanup completing, a promise reaction running at the checkpoint, and a later stale source task being ignored. Its ordered-list equivalent prevents the diagram's arrow positions from becoming the only explanation.

Test ordering with labeled events rather than wall-clock durations. The lab records task:start, settle, cleanup, task:end, microtask:reaction, then next-task:stale. A mutant that records the microtask as a task must fail an exact sequence assertion. Do not infer cross-engine conformance from one browser run.

Also test generation rollover. Start operation A, cancel it, start B, then deliver A's delayed callback. B remains pending until one of its own terminal events arrives. The receipt identifies A's callback as stale rather than merely duplicate.

JSON and CSV exports preserve the ordered timeline, terminal reason, ignored events, and cleanup counters. Reparse each format and compare the event count and winning transition. That catches accidental comma handling, missing reasons, or reordered records in the evidence layer.

Decide against the pattern and publish a receipt

Prefer a normal async function when one lexical flow owns start, await, and return. Prefer a standard API that already returns a promise when it carries correct cancellation and cleanup. Avoid Promise.withResolvers when it would place resolving functions in global state, a broad registry without expiry, or code that cannot name the resource owner.

Feature-detect the method. In an unsupported browser, the lab marks the withResolvers lane unavailable while keeping the constructor control usable. Do not silently install a semantic claim under the native name. A small local constructor equivalent can support your application, but compatibility policy belongs in the product's browser contract.

The release receipt records fixture schema, browser feature availability, adapter kind, scripted events, winning state and reason, ignored events, ordered timeline, generation, listener and timer cleanup, equivalence result, mutation kills, malformed-input rejection, and export reparse status.

This evidence covers the executing browser and bounded fixtures. It is not a performance benchmark, security proof, or cross-engine conformance suite. Promise.withResolvers can improve clarity when the capability has one owner and a finite lifetime; it can amplify ambiguity when settlement escapes.

Run the matrix, save one JSON or CSV receipt beside the adapter test, and revisit the choice when the platform, browser support, or source lifecycle changes. The durable result is not fewer constructor lines. It is a terminal transition whose value, reason, cleanup, and ordering remain explainable.

Runnable local artifact — This is evidence for the executing browser and bounded fixtures, not a cross-engine conformance result or performance benchmark.

Plain text1 line
Run at most 64 validated events over at most 5,000 virtual milliseconds; compare correct adapters, inspect cleanup and ordering, kill six mutants, and export JSON or CSV.